diff --git a/Directory.Packages.props b/Directory.Packages.props index a221a7f..d5f6bd8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,6 @@ Package versions made consistent across all packages referenced in this repository --> - @@ -28,11 +27,9 @@ - - @@ -41,13 +38,13 @@ - + - + - + @@ -66,8 +63,10 @@ - - - + + + + + - + \ No newline at end of file diff --git a/Invoke-Tests.ps1 b/Invoke-Tests.ps1 index 0890e9b..06f1c23 100644 --- a/Invoke-Tests.ps1 +++ b/Invoke-Tests.ps1 @@ -39,7 +39,7 @@ try try { dir (Join-Path $BuildInfo['BuildOutputPath'] 'bin' 'Ubiquity.NET.CommandLine.SrcGen.UT' "$Configuration" 'net10.0' 'Ubiquity.NET.CommandLine.*') - Invoke-External dotnet test Ubiquity.NET.Utils.slnx '-c' $Configuration '-tl:off' '--logger:trx' '--no-build' '-s' '.\x64.runsettings' + Invoke-External dotnet test Ubiquity.NET.Utils.slnx '-c' $Configuration '-tl:off' '--logger:trx' '--no-build' '-s' '.\.runsettings' } finally { diff --git a/src/x64.runsettings b/src/.runsettings similarity index 100% rename from src/x64.runsettings rename to src/.runsettings diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs index ab3681e..010dada 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs @@ -27,5 +27,13 @@ public static NamespaceQualifiedName GetNamespaceQualifiedName( this AttributeDa ? new( [], string.Empty ) : self.AttributeClass.GetNamespaceQualifiedName(); } + + /// Gets the location from the if available + /// to get the location from + /// Location of the attribute or null if not available + public static Location? GetLocation( this AttributeData self ) + { + return self.ApplicationSyntaxReference?.SyntaxTree.GetLocation( self.ApplicationSyntaxReference.Span ); + } } } diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs index 70f7fdd..86b2e29 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs @@ -4,7 +4,9 @@ // Mostly from: https://github.com/Sergio0694/PolySharp/blob/main/src/PolySharp.SourceGenerators/Extensions/CompilationExtensions.cs // Reformatted and adapted to support repo guidelines +#if SUPPORT_VB using Microsoft.CodeAnalysis.VisualBasic; +#endif namespace Ubiquity.NET.CodeAnalysis.Utils { @@ -28,6 +30,12 @@ public static bool HasLanguageVersionAtLeastEqualTo( this Compilation compilatio : csharpCompilation.LanguageVersion >= languageVersion; } +// Support of VB is problematic as [RS1038](https://github.com/dotnet/roslyn/blob/main/docs/roslyn-analyzers/rules/RS1038.md) +// is aggressive and tests for ALL dependencies. So inclusion of a reference in a dependent assembly will trigger that +// To fully resolve this in a general means the language specific parts would need to pull out of this assembly and +// into a distinct one for that language. This is a bit overkill given the need to target any language other than C# +// is rather limited... Until such is needed, just leave out the VB support +#if SUPPORT_VB /// Checks whether a given VB compilation is using at least a given language version. /// The to consider for analysis. /// The minimum language version to check. @@ -39,6 +47,7 @@ public static bool HasLanguageVersionAtLeastEqualTo( this Compilation compilatio ? throw new ArgumentNullException( nameof( compilation ) ) : vbCompilation.LanguageVersion >= languageVersion; } +#endif /// Gets the runtime version by extracting the version from the assembly implementing /// Compilation to get the version information from diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj index d025fac..2a6bffe 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj @@ -34,10 +34,16 @@ - - + - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj index 2bc29f1..faaa7b4 100644 --- a/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj +++ b/src/Ubiquity.NET.CommandLine.Pkg/Ubiquity.NET.CommandLine.Pkg.csproj @@ -7,7 +7,7 @@ To support a meta package where the referenced packages may not exist at build t The Build process for CSPROJ files will require resolving the referenced packages before it "generates" the NuSpec file. The CSPROJ system for MSBUILD will try to restore referenced packages etc... and ultimately requires the ability to find the listed dependencies. (They won't exist yet for this build/repo!) so either a mechanism to control build ordering EVEN -on NuGetRestore is needed, or this approach is used. Given the complexities of trying the former, this approach is used +on NuGet Restore is needed, or this approach is used. Given the complexities of trying the former, this approach is used as it is simpler. --> diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/CommandAnalyzerTests.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/CommandAnalyzerTests.cs new file mode 100644 index 0000000..244a9e2 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/CommandAnalyzerTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + [TestClass] + public class CommandAnalyzerTests + { + public TestContext TestContext { get; set; } + + [TestMethod] + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task Empty_source_analyzes_clean( TestRuntime testRuntime ) + { + var analyzerTest = CreateTestRunner( string.Empty, testRuntime ); + await analyzerTest.RunAsync( TestContext.CancellationToken ); + } + + [TestMethod] + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task Option_attribute_without_command_triggers_diagnostic( TestRuntime testRuntime ) + { + SourceText txt = GetSourceText( nameof(Option_attribute_without_command_triggers_diagnostic), "input.cs" ); + var analyzerTest = CreateTestRunner( txt, testRuntime ); + + // (9,6): warning UNC001: Property attribute OptionAttribute is only allowed on a property in a type attributed with a command attribute. This use will be ignored by the generator. + analyzerTest.ExpectedDiagnostics.AddRange( + [ + new DiagnosticResult("UNC001", DiagnosticSeverity.Warning).WithLocation(9, 6), + ] + ); + + await analyzerTest.RunAsync( TestContext.CancellationToken ); + } + + [TestMethod] + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task FileValidation_attribute_without_command_triggers_diagnostic( TestRuntime testRuntime ) + { + SourceText txt = GetSourceText( nameof(FileValidation_attribute_without_command_triggers_diagnostic), "input.cs" ); + var analyzerTest = CreateTestRunner( txt, testRuntime ); + + // (10,6): warning UNC001: Property attribute FileValidationAttribute is only allowed on a property in a type attributed with a command attribute. This use will be ignored by the generator. + // (10,6): error UNC002: Property attribute FileValidationAttribute is not allowed on a property independent of a qualifying attribute such as OptionAttribute. + analyzerTest.ExpectedDiagnostics.AddRange( + [ + new DiagnosticResult("UNC001", DiagnosticSeverity.Warning).WithLocation(10, 6), + new DiagnosticResult("UNC002", DiagnosticSeverity.Error).WithLocation(10, 6), + ] + ); + + await analyzerTest.RunAsync( TestContext.CancellationToken ); + } + + [TestMethod] + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task FolderValidation_attribute_without_command_triggers_diagnostic( TestRuntime testRuntime ) + { + SourceText txt = GetSourceText( nameof(FolderValidation_attribute_without_command_triggers_diagnostic), "input.cs" ); + var analyzerTest = CreateTestRunner( txt, testRuntime ); + + // (10,6): warning UNC001: Property attribute FolderValidationAttribute is only allowed on a property in a type attributed with a command attribute. This use will be ignored by the generator. + // (10,6): error UNC002: Property attribute FolderValidationAttribute is not allowed on a property independent of a qualifying attribute such as OptionAttribute. + analyzerTest.ExpectedDiagnostics.AddRange( + [ + new DiagnosticResult("UNC001", DiagnosticSeverity.Warning).WithLocation(10, 6), + new DiagnosticResult("UNC002", DiagnosticSeverity.Error).WithLocation(10, 6), + ] + ); + + await analyzerTest.RunAsync( TestContext.CancellationToken ); + } + + [TestMethod] + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task GoldenPath_produces_no_diagnostics( TestRuntime testRuntime ) + { + SourceText txt = GetSourceText( nameof(GoldenPath_produces_no_diagnostics), "input.cs" ); + + var analyzerTest = CreateTestRunner( txt, testRuntime ); + await analyzerTest.RunAsync( TestContext.CancellationToken ); + } + + private AnalyzerTest CreateTestRunner( string source, TestRuntime testRuntime ) + { + return CreateTestRunner( SourceText.From( source ), testRuntime ); + } + + private AnalyzerTest CreateTestRunner( SourceText source, TestRuntime testRuntime ) + { + // Use a test analyzer with language and reference assemblies that match the runtime for the test run + return new SourceAnalyzerTest( testRuntime.DefaultLangVersion ) + { + TestState = + { + Sources = { source }, + ReferenceAssemblies = testRuntime.ReferenceAssemblies, + AdditionalReferences = + { + TestContext.GetUbiquityNetCommandLineLib( testRuntime ) + }, + OutputKind = OutputKind.DynamicallyLinkedLibrary, // Don't require a Main() method + }, + + // Allow ALL diagnostics for testing, input source should contain valid C# code + // but might otherwise trigger the tested analyzer. + CompilerDiagnostics = CompilerDiagnostics.All, + }; + } + + private static SourceText GetSourceText( params string[] nameParts ) + { + return TestHelpers.GetTestText( nameof( CommandAnalyzerTests ), nameParts ); + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs index ac6dd60..9216546 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalNamespaceImports.cs @@ -6,14 +6,15 @@ global using System.Collections.Immutable; global using System.Diagnostics.CodeAnalysis; global using System.IO; +global using System.Linq; global using System.Runtime.InteropServices; -global using System.Text; - -global using Basic.Reference.Assemblies; +global using System.Threading.Tasks; global using Microsoft.CodeAnalysis; global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.Testing; global using Microsoft.CodeAnalysis.Text; global using Microsoft.VisualStudio.TestTools.UnitTesting; global using Ubiquity.NET.SourceGenerator.Test.Utils; +global using Ubiquity.NET.SourceGenerator.Test.Utils.CSharp; diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs index a79c00f..bc164b2 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/GlobalSuppression.cs @@ -6,7 +6,5 @@ // Project-level suppressions either have no target or are given // a specific target and scoped to a namespace, type, member, etc. -using System.Diagnostics.CodeAnalysis; - [assembly: SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Unit Tests" )] [assembly: SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Unit Tests" )] diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/MSTestVerifier.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/MSTestVerifier.cs new file mode 100644 index 0000000..15462b3 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/MSTestVerifier.cs @@ -0,0 +1,113 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + /// Provides an implementation of for Ms Test . + /// + /// This verifier is dependent on the MsTest framework. Each verification method uses + /// in an adapter pattern. + /// + public class MsTestVerifier + : IVerifier + { + /// Initializes a new instance of the class. + public MsTestVerifier( ) + : this( [] ) + { + } + + /// + public virtual void Empty( string collectionName, IEnumerable collection ) + { + Assert.IsEmpty( collection, CreateMessage( $"collection '{collectionName}' is not empty" ) ); + } + + /// + public virtual void NotEmpty( string collectionName, IEnumerable collection ) + { + Assert.IsNotEmpty( collection, CreateMessage( $"collection '{collectionName}' is empty" ) ); + } + + /// + public virtual void LanguageIsSupported( string language ) + { + Assert.IsTrue( language == LanguageNames.CSharp || language == LanguageNames.VisualBasic, CreateMessage( $"Unsupported Language: '{language}'" ) ); + } + + /// + public virtual void Equal( T expected, T actual, string? message = null ) + { + Assert.AreEqual( expected, actual, CreateMessage( message ) ); + } + + /// + public virtual void True( [DoesNotReturnIf( false )] bool assert, string? message = null ) + { + Assert.IsTrue( assert, CreateMessage( message ) ); + } + + /// + public virtual void False( [DoesNotReturnIf( true )] bool assert, string? message = null ) + { + Assert.IsFalse( assert, CreateMessage( message ) ); + } + + /// + [DoesNotReturn] + public virtual void Fail( string? message = null ) + { + Assert.Fail( CreateMessage( message ) ); + } + + /// + public virtual void SequenceEqual( + IEnumerable expected, + IEnumerable actual, + IEqualityComparer? equalityComparer = null, + string? message = null + ) + { + var comparer = new SequenceComparer(equalityComparer); + bool areEqual = comparer.Equals(expected, actual); + if(!areEqual) + { + throw new InvalidOperationException( CreateMessage( message ?? $"Sequences are not equal" ) ); + } + } + + /// + public virtual IVerifier PushContext( string context ) + { + return new MsTestVerifier( Context.Push( context ) ); + } + + /// Initializes a new instance of the class with the specified context. + /// The verification context, with the innermost verification context label at the top of the stack. + /// If is . + private MsTestVerifier( ImmutableStack context ) + { + Context = context ?? throw new ArgumentNullException( nameof( context ) ); + } + + /// Gets the current verification context. The innermost verification context label is the top item on the stack. + private ImmutableStack Context { get; } + + /// + /// Creates a full message for a verifier failure combining the current verification with + /// the for the current verification. + /// + /// The failure message to report. + /// A full failure message containing both the verification context and the failure message for the current test. + private string CreateMessage( string? message ) + { + message ??= string.Empty; + foreach(string frame in Context) + { + message = "Context: " + frame + Environment.NewLine + message; + } + + return message; + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs index 3373111..1b8aa65 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs @@ -9,77 +9,53 @@ public sealed class RootCommandAttributeTests public TestContext TestContext { get; set; } [TestMethod] - public void Basic_golden_path_succeeds( ) + [DataRow( TestRuntime.Net8_0 )] + [DataRow( TestRuntime.Net10_0 )] + public async Task Basic_golden_path_succeeds( TestRuntime testRuntime ) { - var sourceGenerator = new CommandGenerator().AsSourceGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create( - generators: [sourceGenerator], - driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) - ); + const string inputFileName = "input.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 = TestHelpers.GetTestText(nameof(RootCommandAttributeTests), "input.cs"); - SourceText expected = TestHelpers.GetTestText(nameof(RootCommandAttributeTests), "expected.cs"); + SourceText input = GetSourceText( nameof(Basic_golden_path_succeeds), inputFileName ); + SourceText expected = GetSourceText( nameof(Basic_golden_path_succeeds), expectedFileName ); - CSharpCompilation compilation = CreateCompilation(input, TestProgramCSPath); - var diagnostics = compilation.GetDiagnostics( TestContext.CancellationToken ); - foreach(var diagnostic in diagnostics) - { - TestContext.WriteLine( diagnostic.ToString() ); - } - - Assert.HasCount( 0, diagnostics ); - - var results = driver.RunGeneratorAndAssertResults(compilation, [TrackingNames.CommandClass]); - Assert.IsEmpty( results.Diagnostics, "Should not have ANY diagnostics reported during generation" ); - - // validate the generated trees have the correct count and names - Assert.HasCount( 1, results.GeneratedTrees, "Should create 1 'files' during generation" ); - for(int i = 0; i < results.GeneratedTrees.Length; ++i) - { - string expectedName = GeneratedFilePaths[i]; - SyntaxTree tree = results.GeneratedTrees[i]; - - Assert.AreEqual( expectedName, tree.FilePath, "Generated files should use correct name" ); - Assert.AreEqual( Encoding.UTF8, tree.Encoding, $"Generated files should use UTF8. [{expectedName}]" ); - } - - SourceText actual = results.GeneratedTrees[0].GetText( TestContext.CancellationToken ); - string uniDiff = expected.UniDiff(actual); - if(!string.IsNullOrWhiteSpace( uniDiff )) - { - TestContext.WriteLine( uniDiff ); - Assert.Fail( "No Differences Expected" ); - } + var runner = CreateTestRunner(input, testRuntime, [TrackingNames.CommandClass], hintPath, expected ); + await runner.RunAsync( TestContext.CancellationToken ); } - // simple helper for these tests to create a C# Compilation - internal static CSharpCompilation CreateCompilation( + private SourceGeneratorTest CreateTestRunner( SourceText source, - string path, - CSharpParseOptions? parseOptions = default, - CSharpCompilationOptions? compileOptions = default, - List? references = default + TestRuntime testRuntime, + ImmutableArray trackingNames, + string expectedHintPath, + SourceText expectedContent ) { - parseOptions ??= new CSharpParseOptions(LanguageVersion.CSharp14, DocumentationMode.None); - compileOptions ??= new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable ); - - // Default to .NET 10 if not specified. - references ??= [ .. Net100.References.All ]; - references.Add( MetadataReference.CreateFromFile( Path.Combine( Environment.CurrentDirectory, "Ubiquity.NET.CommandLine.dll" ) ) ); - - return CSharpCompilation.Create( "TestAssembly", - [ CSharpSyntaxTree.ParseText( source, parseOptions, path ) ], - references, - compileOptions - ); + // Use a test runner with Caching, language and reference assemblies that match the runtime for the test run + return new CachedSourceGeneratorTest( trackingNames, testRuntime.DefaultLangVersion ) + { + TestState = + { + Sources = { source }, + ReferenceAssemblies = testRuntime.ReferenceAssemblies, + AdditionalReferences = + { + TestContext.GetUbiquityNetCommandLineLib( testRuntime ) + }, + OutputKind = OutputKind.DynamicallyLinkedLibrary, // Don't require a Main() method + GeneratedSources = { (expectedHintPath, expectedContent) } + }, + + // Allow ALL diagnostics for testing, input source should contain valid C# code + // but might otherwise trigger the tested analyzer. + CompilerDiagnostics = CompilerDiagnostics.All, + }; } - private const string TestProgramCSPath = @"input.cs"; - - private readonly ImmutableArray GeneratedFilePaths - = [ - @"Ubiquity.NET.CommandLine.SrcGen\Ubiquity.NET.CommandLine.SrcGen.CommandGenerator\TestNamespace.TestOptions.g.cs", - ]; + private static SourceText GetSourceText(params string[] nameParts) + { + return TestHelpers.GetTestText( nameof( RootCommandAttributeTests ), nameParts ); + } } } diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FileValidation_attribute_without_command_triggers_diagnostic/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FileValidation_attribute_without_command_triggers_diagnostic/input.cs new file mode 100644 index 0000000..51333e9 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FileValidation_attribute_without_command_triggers_diagnostic/input.cs @@ -0,0 +1,12 @@ +using System.IO; + +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +internal class testInput1 +{ + // This attribute alone should trigger UNC002 - Property attribute FileValidation is not allowed on a property independent of a qualifying attribute such as OptionAttribute. + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomePath { get; init; } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FolderValidation_attribute_without_command_triggers_diagnostic/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FolderValidation_attribute_without_command_triggers_diagnostic/input.cs new file mode 100644 index 0000000..51333e9 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/FolderValidation_attribute_without_command_triggers_diagnostic/input.cs @@ -0,0 +1,12 @@ +using System.IO; + +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +internal class testInput1 +{ + // This attribute alone should trigger UNC002 - Property attribute FileValidation is not allowed on a property independent of a qualifying attribute such as OptionAttribute. + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomePath { get; init; } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/GoldenPath_produces_no_diagnostics/input.cs similarity index 100% rename from src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/input.cs rename to src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/GoldenPath_produces_no_diagnostics/input.cs diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/Option_attribute_without_command_triggers_diagnostic/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/Option_attribute_without_command_triggers_diagnostic/input.cs new file mode 100644 index 0000000..4138f5d --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/CommandAnalyzerTests/Option_attribute_without_command_triggers_diagnostic/input.cs @@ -0,0 +1,11 @@ +using System.IO; + +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +internal class testInput1 +{ + [Option( "-o", Description = "Use of this attribute should generate diagnostic UNC001" )] + public required DirectoryInfo SomePath { get; init; } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/Basic_golden_path_succeeds/expected.cs similarity index 100% rename from src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/expected.cs rename to src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/Basic_golden_path_succeeds/expected.cs diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/Basic_golden_path_succeeds/input.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/Basic_golden_path_succeeds/input.cs new file mode 100644 index 0000000..0f3bfe8 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestFiles/RootCommandAttributeTests/Basic_golden_path_succeeds/input.cs @@ -0,0 +1,26 @@ +using System.IO; +using Ubiquity.NET.CommandLine.GeneratorAttributes; + +namespace TestNamespace; + +[RootCommand( Description = "Root command for tests" )] +internal partial class TestOptions +{ + [Option( "-o", Description = "Test SomePath" )] + [FolderValidation( FolderValidation.CreateIfNotExist )] + public required DirectoryInfo SomePath { get; init; } + + [Option( "-b", Description = "Test Some existing Path" )] + [FolderValidation( FolderValidation.ExistingOnly )] + public required DirectoryInfo SomeExistingPath { get; init; } + + [Option( "--thing1", Aliases = [ "-t" ], Required = true, Description = "Test Thing1", HelpName = "Help name for thing1" )] + public bool Thing1 { get; init; } + + // This should be ignored by generator + public string? NotAnOption { get; set; } + + [Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )] + [FileValidation( FileValidation.ExistingOnly )] + public required FileInfo SomeOtherPath { get; init; } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs index e266a2e..9445a86 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestHelpers.cs @@ -1,6 +1,10 @@ // Copyright (c) Ubiquity.NET Contributors. All rights reserved. // Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. +using System.ComponentModel; + +using ReferenceAssemblies = Microsoft.CodeAnalysis.Testing.ReferenceAssemblies; + namespace Ubiquity.NET.CommandLine.SrcGen.UT { internal static class TestHelpers @@ -17,12 +21,68 @@ public static Stream GetTestResourceStream( string testName, string name ) ?? throw new InvalidOperationException( $"Resource '{resourceName}' not found" ); } - public static SourceText GetTestText( string testName, string name ) + public static SourceText GetTestText( string testName, params string[] nameParts ) { - using var strm = GetTestResourceStream( testName, name ); + if(nameParts.Length == 0) + { + throw new ArgumentException( "Must include at least one name part", nameof( nameParts ) ); + } + + using var strm = GetTestResourceStream( testName, string.Join(".", nameParts) ); return SourceText.From( strm ); } + extension( TestContext self ) + { + internal string BuildOutputBinPath + { + get + { + string runDir = self.TestRunDirectory ?? throw new InvalidOperationException("No test run directory"); + return Path.GetFullPath( Path.Combine( runDir, "..", "..", "bin" ) ); + } + } + + public MetadataReference GetUbiquityNetCommandLineLib( TestRuntime testRuntime ) + { + string runtimeName = testRuntime switch + { + TestRuntime.Net8_0 => "net8.0", + TestRuntime.Net10_0 => "net10.0", + _ => throw new InvalidEnumArgumentException(nameof(testRuntime), (int)testRuntime, typeof(TestRuntime)) + }; + + string pathName = Path.Combine( self.BuildOutputBinPath, "Ubiquity.NET.CommandLine", ConfigName, runtimeName, "Ubiquity.NET.CommandLine.dll" ); + return MetadataReference.CreateFromFile( pathName ); + } + } + + #region .NET 10 Reference Assemblies + + /// Gets the .NET 10 reference assemblies + /// + /// Sadly, Microsoft.CodeAnalysis.Testing.ReferenceAssemblies does not contain any reference for .NET 10 + /// (even the latest version of that lib) + /// + /// + public static ReferenceAssemblies Net10 => LazyNet10Refs.Value; + + private static readonly Lazy LazyNet10Refs = new( + static ()=> new( + targetFramework: "net10.0", + referenceAssemblyPackage: new PackageIdentity("Microsoft.NETCore.App.Ref", "10.0.0"), + referenceAssemblyPath: Path.Combine("ref", "net10.0") + ) + ); + #endregion + + // There is no way to detect the configuration at runtime to include the correct + // reference to the DLL so use the compiler define to do the best available. +#if DEBUG + private const string ConfigName = "Debug"; +#else + private const string ConfigName = "Release"; +#endif private const string TestFilesFolderName = "TestFiles"; } } diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestRuntime.cs b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestRuntime.cs new file mode 100644 index 0000000..2e3edc4 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/TestRuntime.cs @@ -0,0 +1,41 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.ComponentModel; + +namespace Ubiquity.NET.CommandLine.SrcGen.UT +{ + /// Test enumeration for use as a data row to test the component for different runtimes + public enum TestRuntime + { + /// .NET 8.0 runtime + Net8_0, + + /// .NET 10.0 runtime + Net10_0 + } + + [SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Extensions of Enum" )] + internal static class TestRuntimeExtensions + { + extension(TestRuntime self) + { + // Analyzer tests use unresolved references + internal Microsoft.CodeAnalysis.Testing.ReferenceAssemblies ReferenceAssemblies + => self switch + { + TestRuntime.Net8_0 => ReferenceAssemblies.Net.Net80, + TestRuntime.Net10_0 => TestHelpers.Net10, + _ => throw new InvalidEnumArgumentException( nameof( self ), (int)self, typeof( TestRuntime ) ), + }; + + internal LanguageVersion DefaultLangVersion + => self switch + { + TestRuntime.Net8_0 => LanguageVersion.CSharp12, + TestRuntime.Net10_0 => LanguageVersion.CSharp14, + _ => throw new InvalidEnumArgumentException( nameof( self ), (int)self, typeof( TestRuntime ) ), + }; + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj index 1cb51da..b9d40eb 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj +++ b/src/Ubiquity.NET.CommandLine.SrcGen.UT/Ubiquity.NET.CommandLine.SrcGen.UT.csproj @@ -7,15 +7,15 @@ - - - - - - + + + + + + @@ -29,20 +29,25 @@ - - + + + + + + - - + + + + + + - + diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Shipped.md b/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..2080dcd --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Unshipped.md b/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..43c19e2 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +UNC000 | Internal | Error | Diagnostics, [Documentation](NotConfigurable) +UNC001 | Usage | Warning | Diagnostics, [Documentation](Unnecessary) +UNC002 | Usage | Error | Diagnostics, [Documentation](NotConfigurable) +UNC003 | Usage | Error | Diagnostics, [Documentation](NotConfigurable) \ No newline at end of file diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs b/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs index fecf04c..5e47c01 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs +++ b/src/Ubiquity.NET.CommandLine.SrcGen/CommandGenerator.cs @@ -38,10 +38,11 @@ var optionClasses { // Do nothing if the target doesn't support what the generated code needs or something is wrong. // Errors are detected by a distinct analyzer; code generators just NOP as fast as possible. + // see: https://csharp-evolution.com/guides/language-by-platform var compilation = context.SemanticModel.Compilation; if( context.Attributes.Length != 1 // Multiple instances not allowed and 0 is just broken. || compilation.Language != "C#" - || !compilation.HasLanguageVersionAtLeastEqualTo(LanguageVersion.CSharp13) + || !compilation.HasLanguageVersionAtLeastEqualTo( LanguageVersion.CSharp12 ) // C# 12 => .NET 8.0 => supported until 2026-11-10 (LTS) || context.TargetSymbol is not INamedTypeSymbol namedTypeSymbol || context.TargetNode is not ClassDeclarationSyntax commandClass ) @@ -82,7 +83,8 @@ private static void Execute( SourceProductionContext context, RootCommandInfo so { var template = new Templates.RootCommandClassTemplate(source); var generatedSource = template.GenerateText(); - context.AddSource( $"{source.TargetName:R}.g.cs", generatedSource ); + string hintPath = $"{source.TargetName:R}.g.cs"; + context.AddSource( hintPath, generatedSource ); } } } diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/CommandLineAnalyzer.cs b/src/Ubiquity.NET.CommandLine.SrcGen/CommandLineAnalyzer.cs new file mode 100644 index 0000000..e522e8d --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/CommandLineAnalyzer.cs @@ -0,0 +1,222 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; + +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +using Ubiquity.NET.Extensions; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + // Sadly C# (and .NET) doesn't have any real concept of a type alias (typedef in C++) + // This simplifies use within this file at least... [Sigh...] +#pragma warning disable IDE0065 // Misplaced using directive +#pragma warning disable SA1200 // Using directives should be placed correctly +#pragma warning disable SA1135 // Using directives should be qualified + using PropertyAttributeHandlerAction = Action; + + // Sadly, this can't use the `PropertyAttributeHandlerAction`... symbol and it must use the actual type... [Sigh...] + using PropertyAttributeHandlerMap = ImmutableDictionary>; + + using SymbolHandlerAction = Action; + using SymbolHandlerMap = ImmutableDictionary>; +#pragma warning restore SA1135 // Using directives should be qualified +#pragma warning restore SA1200 // Using directives should be placed correctly +#pragma warning restore IDE0065 // Misplaced using directive + + /// Analyzer for the command line attribute usage + [DiagnosticAnalyzer( LanguageNames.CSharp )] + public class CommandLineAnalyzer + : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics => Diagnostics.CommandLineAnalyzerDiagnostics; + + /// + public override void Initialize( AnalysisContext context ) + { + context.ConfigureGeneratedCodeAnalysis( GeneratedCodeAnalysisFlags.None ); + context.EnableConcurrentExecution(); + + // As of right now there is no validation of attributes on a type + // [A type for a command is allowed to have no Options/Arguments.] + context.RegisterSymbolAction( OnTypeOrProperty, /*SymbolKind.NamedType,*/ SymbolKind.Property ); + } + + [SuppressMessage( "Design", "CA1031:Do not catch general exception types", Justification = "No loss of information, exception transformed to a diagnostic" )] + private void OnTypeOrProperty( SymbolAnalysisContext context ) + { + try + { + if(!SymbolHandlerMap.TryGetValue( context.Symbol.Kind, out var handler )) + { + Debug.Assert( false, "Unexpected value for kind!" ); + return; + } + + handler( context ); + return; + } + catch(Exception ex) + { + Location? loc = context.Symbol.Locations.Length < 1 ? default : context.Symbol.Locations[0]; + ReportDiagnostic( context, Diagnostics.InternalError, loc, ex.Message ); + } + } + + //private static void OnNamedType( SymbolAnalysisContext context ) + //{ + // if(context.Symbol is not INamedTypeSymbol symbol) + // { + // Debug.Assert( false, "Non type symbol for named type..." ); + // return; + // } + // + // // Currently no validation on a named type symbol + //} + + private static void OnProperty( SymbolAnalysisContext context ) + { + if(context.Symbol is not IPropertySymbol propSymbol) + { + Debug.Assert( false, "Non-property symbol provided to property symbol handler!" ); + return; + } + + // If there is a handler for an attribute, then call it to verify the attribute's information + var attribData = context.Symbol.MatchingAttributes( Constants.GeneratingAttributeNames ); + var attribs = new EquatableAttributeDataCollection( attribData.Select(a=>new EquatableAttributeData(a)) ); + foreach(var attrib in attribData) + { + // lookup a handler for this type of attribute, any other attribute is left alone + // it might be complete bogus, but that's not validated by this analyzer. + if(PropertyHandlerMap.TryGetValue( attrib.GetNamespaceQualifiedName(), out var handler )) + { + handler( context, propSymbol, attrib.GetLocation(), attribs ); + } + } + } + + private static void OnOptionAttribute( SymbolAnalysisContext context, IPropertySymbol symbol, Location? attribLoc, EquatableAttributeDataCollection _ ) + { + // Verify it is applied to a property in a type with a command attribute + VerifyCommandAttribute( context, attribLoc, Constants.OptionAttribute.SimpleName ); + + // Additional validations... + } + + private static void OnFileValidationAttribute( SymbolAnalysisContext context, IPropertySymbol symbol, Location? attribLoc, EquatableAttributeDataCollection attribs ) + { + Location? propertyLoc = symbol.Locations.Length < 1 ? default : symbol.Locations[0]; + + // Verify it is applied to a property in a type with a command attribute + VerifyCommandAttribute( context, attribLoc, Constants.FileValidationAttribute.SimpleName ); + + // Verify an Option property (or maybe an argument attribute once supported) exists + VerifyHasConstrainedAttribute( context, attribLoc, attribs ); + + // Verify type of the property is System.IO.FileInfo. + VerifyPropertyType( context, symbol, Constants.FileInfo, propertyLoc, Constants.FileValidationAttribute.SimpleName ); + + // Additional validations... + } + + private static void OnFolderValidationAttribute( SymbolAnalysisContext context, IPropertySymbol symbol, Location? attribLoc, EquatableAttributeDataCollection attribs ) + { + Location? propertyLoc = symbol.Locations.Length < 1 ? default : symbol.Locations[0]; + + // Verify it is applied to a property in a type with a command attribute + VerifyCommandAttribute( context, attribLoc, Constants.FolderValidationAttribute.SimpleName ); + + // Verify an Option property (or maybe an argument attribute once supported) exists + VerifyHasConstrainedAttribute( context, propertyLoc, attribs ); + + // Verify type of the property is System.IO.FileInfo. + VerifyPropertyType( context, symbol, Constants.DirectoryInfo, propertyLoc, Constants.FolderValidationAttribute.SimpleName ); + + // Additional validations... + } + + /// Reports a diagnostic if the containing type for the current symbol has a command attribute + /// Context for the symbol to test + /// Location of the attribute requiring the attribute on the parent type + /// Name of the attribute (For reporting diagnostics) + /// + /// At present, the only attribute that qualifies as a command attribute is . + /// In the future it is possible that this tests for another attribute for a sub-command. + /// + /// and are for the actual attribute that requires a command + /// attribute on the containing type. These are used in forming the diagnostic message text. + /// + private static void VerifyCommandAttribute( SymbolAnalysisContext context, Location? attribLoc, string attribName ) + { + // At present, there is only RootCommandAttribute, but in future a sub command attribute may exist + var parentAttributes = context.Symbol.ContainingType.MatchingAttributes([Constants.RootCommandAttribute]); + if(parentAttributes.IsDefaultOrEmpty) + { + ReportDiagnostic( context, Diagnostics.MissingCommandAttribute, attribLoc, attribName ); + } + } + + /// Verifies (and reports a diagnostic if not) that a set of attributes contains a required constraint + /// Context to use for reporting diagnostics + /// Location to use for any diagnostics reported + /// Set of attributes to check + /// + /// The is used to report diagnostics that normally references the attribute + /// that is missing the constrained attribute. (ex. The `FileValidation` or `FolderValidation` that does NOT + /// have an `OptionAttribute` that it validates. + /// + private static void VerifyHasConstrainedAttribute( SymbolAnalysisContext context, Location? diagnosticLoc, EquatableAttributeDataCollection attribs ) + { + // Verify an Option property (or maybe an argument attribute once supported) exists + if(!attribs.TryGetValue( Constants.OptionAttribute, out _ )) + { + ReportDiagnostic( context, Diagnostics.MissingConstraintAttribute, diagnosticLoc, Constants.FileValidationAttribute.SimpleName ); + } + } + + /// Verifies a property has an expected type + /// Context to use for reporting diagnostics + /// Property symbol to test + /// Name of the expected type + /// Location of the attribute that requiring a specific type + /// Name of the attribute that requires a specific type + /// + /// The and are used in any diagnostics + /// reported. That is, they reference the attribute that requires a specific type. It is debatable + /// if the location of the return type is wrong or the attribute is wrong... + /// + private static void VerifyPropertyType( SymbolAnalysisContext context, IPropertySymbol symbol, NamespaceQualifiedName expectedType, Location? attribLoc, string attribName ) + { + if(symbol.Type.GetNamespaceQualifiedName() != expectedType) + { + ReportDiagnostic( context, Diagnostics.IncorrectPropertyType, attribLoc, attribName, expectedType.ToString( "A", null ) ); + } + } + + private static void ReportDiagnostic( SymbolAnalysisContext context, DiagnosticDescriptor descriptor, Location? loc, params object[] args ) + { + context.ReportDiagnostic( Diagnostic.Create( descriptor, loc, args ) ); + } + + private static readonly SymbolHandlerMap SymbolHandlerMap + = new DictionaryBuilder() + { + // [SymbolKind.NamedType] = OnNamedType, + [SymbolKind.Property] = OnProperty, + }.ToImmutable(); + + private static readonly PropertyAttributeHandlerMap PropertyHandlerMap + = new DictionaryBuilder() + { + [Constants.OptionAttribute] = OnOptionAttribute, + [Constants.FileValidationAttribute] = OnFileValidationAttribute, + [Constants.FolderValidationAttribute] = OnFolderValidationAttribute, + }.ToImmutable(); + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Diagnostics.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Diagnostics.cs new file mode 100644 index 0000000..c08411c --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Diagnostics.cs @@ -0,0 +1,79 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; + +using Ubiquity.NET.CommandLine.SrcGen.Properties; + +namespace Ubiquity.NET.CommandLine.SrcGen +{ + internal class Diagnostics + { + internal static class IDs + { + internal const string InternalError = "UNC000"; + internal const string MissingCommandAttribute = "UNC001"; + internal const string MissingConstraintAttribute = "UNC002"; + internal const string IncorrectPropertyType = "UNC003"; + } + + private static LocalizableResourceString Localized( string resName ) + { + return new LocalizableResourceString( resName, Resources.ResourceManager, typeof( Resources ) ); + } + + internal static ImmutableArray CommandLineAnalyzerDiagnostics + => [ + InternalError, + MissingCommandAttribute, + MissingConstraintAttribute, + IncorrectPropertyType + ]; + + internal static readonly DiagnosticDescriptor InternalError = new( + id: IDs.InternalError, + title: Localized(nameof(Resources.InternalError_Title)), + messageFormat: Localized(nameof(Resources.InternalError_MessageFormat)), + category: "Internal", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localized(nameof(Resources.InternalError_Description)), + WellKnownDiagnosticTags.NotConfigurable + ); + + internal static readonly DiagnosticDescriptor MissingCommandAttribute = new( + id: IDs.MissingCommandAttribute, + title: Localized(nameof(Resources.MissingCommandAttribute_Title)), + messageFormat: Localized(nameof(Resources.MissingCommandAttribute_MessageFormat)), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Localized(nameof(Resources.MissingCommandAttribute_Description)), + WellKnownDiagnosticTags.Unnecessary, + WellKnownDiagnosticTags.Compiler + ); + + internal static readonly DiagnosticDescriptor MissingConstraintAttribute = new( + id: IDs.MissingConstraintAttribute, + title: Localized(nameof(Resources.MissingConstraintAttribute_Title)), + messageFormat: Localized(nameof(Resources.MissingConstraintAttribute_MessageFormat)), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localized(nameof(Resources.MissingConstraintAttribute_Description)), + WellKnownDiagnosticTags.Compiler + ); + + internal static readonly DiagnosticDescriptor IncorrectPropertyType = new( + id: IDs.IncorrectPropertyType, + title: Localized(nameof(Resources.IncorrectPropertyType_Title)), + messageFormat: Localized(nameof(Resources.IncorrectPropertyType_MessageFormat)), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Localized(nameof(Resources.IncorrectPropertyType_Description)), + WellKnownDiagnosticTags.Compiler, + WellKnownDiagnosticTags.NotConfigurable + ); + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.Designer.cs b/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.Designer.cs new file mode 100644 index 0000000..39a5749 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.Designer.cs @@ -0,0 +1,171 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Ubiquity.NET.CommandLine.SrcGen.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Ubiquity.NET.CommandLine.SrcGen.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Property has incorrect type for attribute. + /// + internal static string IncorrectPropertyType_Description { + get { + return ResourceManager.GetString("IncorrectPropertyType_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute '{0}' requires a property of type '{1}'.. + /// + internal static string IncorrectPropertyType_MessageFormat { + get { + return ResourceManager.GetString("IncorrectPropertyType_MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property has incorrect type for attribute. + /// + internal static string IncorrectPropertyType_Title { + get { + return ResourceManager.GetString("IncorrectPropertyType_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An internal analyzer exception occurred.. + /// + internal static string InternalError_Description { + get { + return ResourceManager.GetString("InternalError_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Exception message is: '{0}'. + /// + internal static string InternalError_MessageFormat { + get { + return ResourceManager.GetString("InternalError_MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Internal Error. + /// + internal static string InternalError_Title { + get { + return ResourceManager.GetString("InternalError_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute only allowed on an attributed command class. + /// + internal static string MissingCommandAttribute_Description { + get { + return ResourceManager.GetString("MissingCommandAttribute_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute '{0}' is only allowed on a property in a type attributed with a command attribute. This use will be ignored by the generator.. + /// + internal static string MissingCommandAttribute_MessageFormat { + get { + return ResourceManager.GetString("MissingCommandAttribute_MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command attribute on containing type. + /// + internal static string MissingCommandAttribute_Title { + get { + return ResourceManager.GetString("MissingCommandAttribute_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute not allowed standalone. + /// + internal static string MissingConstraintAttribute_Description { + get { + return ResourceManager.GetString("MissingConstraintAttribute_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute '{0}' is not allowed on a property independent of a qualifying attribute such as OptionAttribute.. + /// + internal static string MissingConstraintAttribute_MessageFormat { + get { + return ResourceManager.GetString("MissingConstraintAttribute_MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Property attribute not allowed standalone. + /// + internal static string MissingConstraintAttribute_Title { + get { + return ResourceManager.GetString("MissingConstraintAttribute_Title", resourceCulture); + } + } + } +} diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.resx b/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.resx new file mode 100644 index 0000000..4f328de --- /dev/null +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Properties/Resources.resx @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + An internal analyzer exception occurred. + An internal error occurred in the analyzer/fixer + + + Exception message is: '{0}' + + + Internal Error + + + Property attribute only allowed on an attributed command class + + + Property attribute '{0}' is only allowed on a property in a type attributed with a command attribute. This use will be ignored by the generator. + + + Missing command attribute on containing type + + + Property attribute not allowed standalone + + + Property attribute '{0}' is not allowed on a property independent of a qualifying attribute such as OptionAttribute. + + + Property attribute not allowed standalone + + + Property has incorrect type for attribute + + + Property attribute '{0}' requires a property of type '{1}'. + + + Property has incorrect type for attribute + + \ No newline at end of file diff --git a/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj b/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj index 5cd96a9..6aceaf7 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj +++ b/src/Ubiquity.NET.CommandLine.SrcGen/Ubiquity.NET.CommandLine.SrcGen.csproj @@ -9,7 +9,7 @@ --> netstandard2.0 - + 12 enable true @@ -31,6 +31,7 @@ Apache-2.0 WITH LLVM-exception $(NoWarn);NU5128 + en-US @@ -57,8 +58,6 @@ - - @@ -75,6 +74,19 @@ + + + True + True + Resources.resx + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + diff --git a/src/Ubiquity.NET.CommandLine.UT/ArgumentExceptionReporterTests.cs b/src/Ubiquity.NET.CommandLine.UT/ArgumentExceptionReporterTests.cs index cedbcb3..74d5b6c 100644 --- a/src/Ubiquity.NET.CommandLine.UT/ArgumentExceptionReporterTests.cs +++ b/src/Ubiquity.NET.CommandLine.UT/ArgumentExceptionReporterTests.cs @@ -6,8 +6,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; -using Ubiquity.NET.CommandLine; - namespace Ubiquity.NET.CommandLine.UT { [TestClass] diff --git a/src/Ubiquity.NET.Extensions.UT/LocalizableStringTests.cs b/src/Ubiquity.NET.Extensions.UT/LocalizableStringTests.cs index 9c59543..e5ee11d 100644 --- a/src/Ubiquity.NET.Extensions.UT/LocalizableStringTests.cs +++ b/src/Ubiquity.NET.Extensions.UT/LocalizableStringTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Ubiquity.NET Contributors. All rights reserved. // Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. -using Microsoft.VisualStudio.TestTools.UnitTesting; - namespace Ubiquity.NET.Extensions.UT { [TestClass] diff --git a/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj b/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj index 6128a0c..0a38abd 100644 --- a/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj +++ b/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj @@ -35,8 +35,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj b/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj index a7c1b09..874a922 100644 --- a/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj +++ b/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj @@ -32,7 +32,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/CachedSourceGeneratorTest.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/CachedSourceGeneratorTest.cs new file mode 100644 index 0000000..f3f86e1 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/CachedSourceGeneratorTest.cs @@ -0,0 +1,161 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Model; +using Microsoft.CodeAnalysis.Text; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp +{ + /// implementation that verifies a generator only uses cached values + /// Type of the generator; Must implement + /// Type of the verifier; Must implement + /// + /// The verifier is used to adapt to a variety of test frameworks in a consistent manner. It is usually, + /// but may be any type implementing the interface. + /// The are used to determine what is verified for cached results. Only the results with matching + /// names is validated for cached results. All other diagnostics apply, that is even if is empty the + /// diagnostics produced are still validated as well as the generated output. The cached behavior is only tested AFTER all the + /// other test states are verified. + /// + public class CachedSourceGeneratorTest + : LanguageVerionsSourceGeneratorTest + where TGenerator : IIncrementalGenerator, new() + where TVerifier : IVerifier, new() + { + /// Initializes a new instance of the class. + /// Tracking names to validate for cached results (Others are not validated for that) + /// Version of the language for the generator + /// Nullability behavior for this generator; default is to enable it for language versions that support it. + public CachedSourceGeneratorTest( + ImmutableArray trackingNames, + LanguageVersion languageVersion, + NullableContextOptions? nullableContextOptions = null + ) + : base(languageVersion, nullableContextOptions) + { + TrackingNames = trackingNames; + } + + /// Gets the tracking names to use for this test + public ImmutableArray TrackingNames { get; } + + /// + /// This will run the test AND validate cached output + protected override async Task RunImplAsync( CancellationToken cancellationToken ) + { + // This replicates much of what is in the base type in order to expose the driver + // or, more particularly the driver results of multiple passes of the same compilation + // to ensure that both runs produce the same results AND that the second run uses + // only cached results. + + if(!TestState.GeneratedSources.Any()) + { + // Verify the test state has at least one source, which may or may not be generated + Verify.NotEmpty( $"{nameof( TestState )}.{nameof( SolutionState.Sources )}", TestState.Sources ); + } + + SolutionState testState = GetSolutionState(); + Project project = await CreateProjectAsync( testState, cancellationToken ); + + Compilation compilation = await GetAndVerifyCompilation( project, cancellationToken ); + + // clone for use in validating cached behavior later + var compilationClone = compilation.Clone(); + + var sourceGenerator = new TGenerator().AsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [sourceGenerator], + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true) + ); + + // save the resulting immutable driver for use in second run. + driver = driver.RunGenerators( compilation, cancellationToken ); + GeneratorDriverRunResult runResult1 = driver.GetRunResult(); + Verify.Empty( "Result diagnostics", runResult1.Diagnostics ); + + // validate the generated trees have the correct count and names + var expected = testState.GeneratedSources; + Verify.Equal( expected.Count, runResult1.GeneratedTrees.Length, $"Should generate {expected.Count} 'files' during generation Actual count is {runResult1.GeneratedTrees.Length}." ); + VerifyResultsEqual( runResult1, expected, cancellationToken ); + VerifyCached( driver, compilationClone, runResult1, cancellationToken ); + } + + private static async Task GetAndVerifyCompilation( Project project, CancellationToken cancellationToken ) + { + Verify.True( project.SupportsCompilation, "Project must support compilation for testing" ); + + // Previous Assertion validates compilation won't be null. + var compilation = (await project.GetCompilationAsync( cancellationToken ))!; + + var diagnostics = compilation.GetDiagnostics( cancellationToken ); + var bldr = new StringBuilder(); + foreach(var diagnostic in diagnostics) + { + bldr.AppendLine( diagnostic.ToString() ); + } + + // Report compilation diagnostics as an error + Verify.Equal( 0, diagnostics.Length, bldr.ToString() ); + return compilation; + } + + private static void VerifyResultsEqual( GeneratorDriverRunResult runResult1, SourceFileCollection expected, CancellationToken cancellationToken ) + { + for(int i = 0; i < expected.Count; ++i) + { + (string hintPath, SourceText expectedText) = expected[ i ]; + + SyntaxTree tree = runResult1.GeneratedTrees[i]; + + Verify.Equal( hintPath, tree.FilePath, "Generated files should use correct name" ); + Verify.Equal( Encoding.UTF8, tree.Encoding, $"Generated files should use UTF8. [{hintPath}]" ); + + SourceText actualText = runResult1.GeneratedTrees[i].GetText( cancellationToken ); + Verify.AreEqual( expectedText, actualText, "Generated source should match expected content" ); + } + } + + private void VerifyCached( GeneratorDriver driver, Compilation compilationClone, GeneratorDriverRunResult runResult1, CancellationToken cancellationToken ) + { + if(!TrackingNames.IsDefaultOrEmpty) + { + GeneratorDriverRunResult runResult2 = driver.RunGenerators( compilationClone, cancellationToken ) + .GetRunResult(); + + Verify.AreEqual( runResult1, runResult2, TrackingNames ); + Verify.Cached( runResult2 ); + } + } + + private Task CreateProjectAsync( SolutionState testState, CancellationToken cancellationToken ) + { + var evaluatedState = new EvaluatedProjectState( testState, ReferenceAssemblies ); + var additionalProjects = ( from proj in TestState.AdditionalProjects.Values + select new EvaluatedProjectState(proj, ReferenceAssemblies) + ).ToImmutableArray(); + + return CreateProjectAsync( evaluatedState, additionalProjects, cancellationToken ); + } + + private SolutionState GetSolutionState( ) + { + var analyzers = GetDiagnosticAnalyzers().ToArray(); + var defaultDiagnostic = GetDefaultDiagnostic(analyzers); + var supportedDiagnostics = analyzers.SelectMany(analyzer => analyzer.SupportedDiagnostics) + .ToImmutableArray(); + + var fixableDiagnostics = ImmutableArray.Empty; + return TestState.WithInheritedValuesApplied(null, fixableDiagnostics) + .WithProcessedMarkup(MarkupOptions, defaultDiagnostic, supportedDiagnostics, fixableDiagnostics, DefaultFilePath); + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LangVersionExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LangVersionExtensions.cs new file mode 100644 index 0000000..82d60a9 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LangVersionExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp +{ + /// Utility class to provide extensions to + [SuppressMessage( "Design", "CA1034: Nested types should not be visible", Justification = "Bogus; extension see: https://github.com/dotnet/sdk/issues/51681, and https://github.com/dotnet/roslyn-analyzers/issues/7765" )] + public static class LangVersionExtensions + { + extension( LanguageVersion self ) + { + /// Gets the default nullability for a specific language + /// Any unknown values for language version gets a result of a default + public NullableContextOptions DefaultNullability + => self switch + { + LanguageVersion.CSharp1 or + LanguageVersion.CSharp2 or + LanguageVersion.CSharp3 or + LanguageVersion.CSharp4 or + LanguageVersion.CSharp5 or + LanguageVersion.CSharp6 or + LanguageVersion.CSharp7 or + LanguageVersion.CSharp7_1 or + LanguageVersion.CSharp7_2 or + LanguageVersion.CSharp7_3 => NullableContextOptions.Disable, + + LanguageVersion.CSharp8 or + LanguageVersion.CSharp9 or + LanguageVersion.CSharp10 or + LanguageVersion.CSharp11 or + LanguageVersion.CSharp12 or + LanguageVersion.CSharp13 or + LanguageVersion.CSharp14 => NullableContextOptions.Enable, + _ => NullableContextOptions.Disable, + }; + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LanguageVerionsSourceGeneratorTest.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LanguageVerionsSourceGeneratorTest.cs new file mode 100644 index 0000000..402c88e --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/LanguageVerionsSourceGeneratorTest.cs @@ -0,0 +1,53 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp +{ + /// Source generator tests for C# that allows specification of the language + /// Source generator type + /// Verifier type + /// + /// This type is generally used as a base class for language specific test types. It doesn't include logic/overloads + /// to test for cached results, which is an important part of testing a source generator. The design of the + /// assumes only a single pass which isn't + /// sufficient for verification of cached state. For full cached testing use + /// which uses this as a base. + /// + public class LanguageVerionsSourceGeneratorTest + : CSharpSourceGeneratorTest + where TSourceGenerator : new() + where TVerifier : IVerifier, new() + { + /// Initializes a new instance of the class. + /// Version of the language for this test + /// Nullability context to use for compilation [default: is based on ] + public LanguageVerionsSourceGeneratorTest(LanguageVersion ver, NullableContextOptions? nullableContextOptions = null) + { + LanguageVersion = ver; + NullableContextOptions = nullableContextOptions ?? LanguageVersion.DefaultNullability; + } + + /// Gets the version of the language to use for the tests + public LanguageVersion LanguageVersion { get; } + + /// Gets the options that determines how nullability is handled + public NullableContextOptions NullableContextOptions { get; } + + /// + protected override ParseOptions CreateParseOptions( ) + { + return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion); + } + + /// + protected override CompilationOptions CreateCompilationOptions( ) + { + return new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true, nullableContextOptions: NullableContextOptions ); + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceAnalyzerTest.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceAnalyzerTest.cs new file mode 100644 index 0000000..e8580ec --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceAnalyzerTest.cs @@ -0,0 +1,54 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Collections.Generic; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp +{ + /// Source analyzer tests for C# that allows specification of the language + /// Analyzer type + /// Verifier type + public class SourceAnalyzerTest : AnalyzerTest + where TAnalyzer : DiagnosticAnalyzer, new() + where TVerifier : IVerifier, new() + { + /// Initializes a new instance of the class. + /// Version of the language for the testing + /// Nullability option to use for the test compilation + public SourceAnalyzerTest(LanguageVersion? languageVersion = null, NullableContextOptions? nullableContextOptions = null) + { + // .NET standard 2.0 is the lowest still supported, which uses C# 7.3 + LanguageVersion = languageVersion ?? LanguageVersion.CSharp7_3; + NullableContextOptions = nullableContextOptions ?? LanguageVersion.DefaultNullability; + } + + /// + protected override string DefaultFileExt => "cs"; + + /// + public override string Language => LanguageNames.CSharp; + + /// Gets the version of the language to use for the tests + public LanguageVersion LanguageVersion { get; } + + /// Gets the options that determines how nullability is handled + public NullableContextOptions NullableContextOptions { get; } + + /// + protected override CompilationOptions CreateCompilationOptions( ) + => new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true, nullableContextOptions: NullableContextOptions ); + + /// + protected override ParseOptions CreateParseOptions( ) + => new CSharpParseOptions( LanguageVersion, DocumentationMode.Diagnose ); + + /// + protected override IEnumerable GetDiagnosticAnalyzers( ) + => [new TAnalyzer()]; + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs deleted file mode 100644 index 25bc10f..0000000 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Testing; - -namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp -{ - /// Source generator tests for C# that allows specification of the language - /// Source generator type - /// Verifier type - public class SourceGeneratorTest - : Microsoft.CodeAnalysis.CSharp.Testing.CSharpSourceGeneratorTest - where TSourceGenerator : new() - where TVerifier : IVerifier, new() - { - /// Initializes a new instance of the class. - /// Version of the language for this test - public SourceGeneratorTest(LanguageVersion ver) - { - LanguageVersion = ver; - } - - /// - /// Creates parse options - protected override ParseOptions CreateParseOptions( ) - { - return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion); - } - - private readonly LanguageVersion LanguageVersion; - } -} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs deleted file mode 100644 index 0b692ed..0000000 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace Ubiquity.NET.SourceGenerator.Test.Utils -{ - /// Comparer to test an array (element by element) for equality - public class EnumerableObjectComparer - : IEqualityComparer> - { - /// - [SuppressMessage( "StyleCop.CSharp.NamingRules", "SA1305:Field names should not use Hungarian notation", Justification = "xValues and yValues are not Hungarian names" )] - public bool Equals(IEnumerable? x, IEnumerable? y) - { - ArgumentNullException.ThrowIfNull(x); - ArgumentNullException.ThrowIfNull(y); - - var xValues = x.ToImmutableArray(); - var yValues = x.ToImmutableArray(); - return xValues.Length == yValues.Length - && xValues.Zip(yValues, (a, b) => a.Equals(b)).All(x => x); - } - - /// - public int GetHashCode([DisallowNull] IEnumerable obj) - { - return obj.GetHashCode(); - } - - /// Default constructed comparer. - public static readonly EnumerableObjectComparer Default = new(); - } -} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/ExpectedInfo.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ExpectedInfo.cs new file mode 100644 index 0000000..f87ac26 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ExpectedInfo.cs @@ -0,0 +1,15 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.CodeAnalysis.Text; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Record to hold the hint path and content of an expected source generation + /// Hint path for the file + /// Expected content of the file + [SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Simple record" )] + public readonly record struct ExpectedInfo( string HintPath, SourceText Content ); +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs index 13b4f0c..004b5f7 100644 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs @@ -5,8 +5,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.CodeAnalysis.Testing; namespace Ubiquity.NET.SourceGenerator.Test.Utils { @@ -15,6 +14,7 @@ public static class GeneratorDriverExtensions { /// Runs a source generator twice and validates the results /// Driver to use for the run + /// Verifier to use for asserting the results /// Compilation to use for the run /// Array of names to filter all of the internal tracking names /// Results of first run @@ -25,7 +25,8 @@ public static class GeneratorDriverExtensions /// public static GeneratorDriverRunResult RunGeneratorAndAssertResults( this GeneratorDriver driver, - CSharpCompilation compilation, + IVerifier verify, + Compilation compilation, ImmutableArray trackingNames ) { @@ -38,8 +39,9 @@ ImmutableArray trackingNames GeneratorDriverRunResult runResult1 = driver.GetRunResult(); GeneratorDriverRunResult runResult2 = driver.RunGenerators(compilationClone) .GetRunResult(); - Assert.That.AreEqual(runResult1, runResult2, trackingNames); - Assert.That.Cached(runResult2); + + verify.AreEqual(runResult1, runResult2, trackingNames); + verify.Cached(runResult2); return runResult1; } } diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs deleted file mode 100644 index d433a56..0000000 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; - -using Microsoft.CodeAnalysis; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Ubiquity.NET.SourceGenerator.Test.Utils -{ - // NOTE: due to bug https://github.com/dotnet/roslyn/issues/78042 this is not convertible to - // the new 'extension' keyword. The problem is in the compiler generated lambda for the LINQ - // expressions. Fortunately, the "old" syntax still works fine... - - /// Utility class to implement extensions for - public static class MsTestAssertExtensions - { - /// Extension method for use with to validate two are equivalent - /// Unused, provides extension support - /// Results of first run - /// Results of second run - /// Names of custom tracking steps to validate - public static void AreEqual( - this Assert _, - GeneratorDriverRunResult r1, - GeneratorDriverRunResult r2, - ImmutableArray trackingNames - ) - { - var trackedSteps1 = r1.GetTrackedSteps(trackingNames); - var trackedSteps2 = r2.GetTrackedSteps(trackingNames); - - // Assert the static requirements - Assert.AreNotEqual(0, trackedSteps1.Count, "Should not be an empty set of steps matching tracked names"); - Assert.HasCount(trackedSteps1.Count, trackedSteps2, "Both runs should have same number of tracked steps"); - bool hasSameKeys = trackedSteps1.Zip(trackedSteps2, ( s1, s2 ) => trackedSteps2.ContainsKey(s1.Key) && trackedSteps1.ContainsKey(s2.Key)) - .All(x => x); - Assert.IsTrue(hasSameKeys, "Both sets of runs should have the same keys"); - - // loop through all KVPs of name to step in result set 1 - // assert that the second run steps for the same tracking name are equal. - foreach (var (trackingName, runSteps1) in trackedSteps1) - { - var runSteps2 = trackedSteps2[trackingName]; - Assert.That.AreEqual(runSteps1, runSteps2, trackingName); - } - } - - /// - /// Extension method for use with to validate each member of a pair of - /// are equivalent. - /// - /// Unused, provides extension support - /// Array of steps to test against - /// Array of steps to assert are equal to the elements of - /// Tracking name of the step for use in diagnostic messages - /// - /// This uses the built-in extensibility point to perform asserts on - /// each member of the input arrays. Each is tested for equality and this only passes - /// if ALL members are equal. - /// - public static void AreEqual( - this Assert _, - ImmutableArray steps1, - ImmutableArray steps2, - string stepTrackingName - ) - { - Assert.HasCount(steps1.Length, steps2, "Step lengths should be equal"); - for (int i = 0; i < steps1.Length; ++i) - { - var runStep1 = steps1[i]; - var runStep2 = steps2[i]; - - IEnumerable outputs1 = runStep1.Outputs.Select(x => x.Value); - IEnumerable outputs2 = runStep2.Outputs.Select(x => x.Value); - - Assert.AreEqual(outputs1, outputs2, EnumerableObjectComparer.Default, $"{stepTrackingName} should produce cacheable outputs"); - Assert.That.OutputsCachedOrUnchanged(runStep2, stepTrackingName); - Assert.That.ObjectGraphContainsValidSymbols(runStep1, stepTrackingName); - } - } - - /// Extension method for use with to assert all of the tracked output steps are cached - /// Unused, provides extension support - /// Run results to test for cached outputs - public static void Cached( this Assert _, GeneratorDriverRunResult driverRunResult ) - { - // verify the second run only generated cached source outputs - var uncachedSteps = from generatorRunResult in driverRunResult.Results - from trackedStepKvp in generatorRunResult.TrackedOutputSteps - from runStep in trackedStepKvp.Value // name is used in select if condition passes - from valueReasonTuple in runStep.Outputs // all outputs must have a cached reason. - where valueReasonTuple.Reason != IncrementalStepRunReason.Cached - select runStep.Name; - - foreach (string stepTrackingName in uncachedSteps) - { - Assert.Fail($"Step name {stepTrackingName ?? ""} contains uncached results for second run!"); - } - } - - /// Extension method for use with to validate that an object is not of a banned type - /// [ignored] syntactic sugar for extension method - /// object node to test - /// reason message for any failures - /// parameters for construction of any exceptions - public static void NotBannedType( - this Assert _, - object? node, - [StringSyntax(StringSyntaxAttribute.CompositeFormat)] string message, - params string[] parameters - ) - { - // can't validate anything for the type of a null - if (node is not null) - { - string msg = string.Format(CultureInfo.CurrentCulture, message, parameters); - - // While this is not a comprehensive list. it covers the most common mistakes directly - Assert.IsNotInstanceOfType(node, msg); - Assert.IsNotInstanceOfType(node, msg); - Assert.IsNotInstanceOfType(node, msg); - } - } - - /// - /// Extension method for use with to validate that all - /// are either or - /// - /// Unused, provides extension support - /// Step of the run to test - /// Tracking name to use in assertion messages on failures - public static void OutputsCachedOrUnchanged( - this Assert _, - IncrementalGeneratorRunStep runStep, - string stepTrackingName - ) - { - Assert.IsFalse( - runStep.Outputs.Any(x => x.Reason != IncrementalStepRunReason.Cached && x.Reason != IncrementalStepRunReason.Unchanged), - $"{stepTrackingName} should have only cached or unchanged reasons!" - ); - } - - /// Extension method to validate that the output of a doesn't use any banned types. - /// [Ignored] - /// Run step to validate - /// Name of the step to aid in diagnostics - /// - /// - /// It is debatable if this should be used in a test or an analyzer. In a test it is easy - /// to omit from the tests (or not test at all in early development cycles). - /// An analyzer can operate as you type code in the editor or when you compile the code so - /// has a greater chance of catching erroneous use. Unfortunately no such analyzer exists - /// as of yet. [It's actually hard to define the rules an analyzer should follow]. So this - /// will do the best it can for now... - /// - public static void ObjectGraphContainsValidSymbols( - this Assert _, - IncrementalGeneratorRunStep runStep, - string stepTrackingName - ) - { - // Including the stepTrackingName in error messages to make it easier to isolate issues - const string because = "Step shouldn't contain banned symbols or non-equatable types. [{0}; {1}]"; - var visited = new HashSet(); - - // Check all of the outputs - probably overkill, but why not - foreach (var (obj, _) in runStep.Outputs) - { - Visit(obj, visited, because, stepTrackingName, runStep.Name ?? ""); - } - - // Private static function to recursively validate an object is cacheable - static void Visit( - object? node, - HashSet visitedNodes, - [StringSyntax(StringSyntaxAttribute.CompositeFormat)] string message, - params string[] parameters - ) - { - // If we've already seen this object, or it's null, stop. - if (node is null || !visitedNodes.Add(node)) - { - return; - } - - Assert.That.NotBannedType(node, message, parameters); - - // Skip basic types and anything equatable, this includes - // any equatable collections such as EquatableArray as - // that implies all elements are equatable already. - // For now equatable type skipping is disabled as testing for - // that is complex... - Type type = node.GetType(); - if (type.IsBasicType() /*|| type.IsEquatable()*/) - { - return; - } - - // If the object is a collection, check each of the values - if (node is IEnumerable collection and not string && !IsDefaultImmutable(node)) - { - foreach (object element in collection) - { - // recursively check each element in the collection - Visit(element, visitedNodes, message, parameters); - } - } - else - { - // Recursively check each field in the object - foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) - { - object? fieldValue = field.GetValue(node); - Visit(fieldValue, visitedNodes, message, parameters); - } - } - } - } - - // This prevents visiting an Immutable collection that is default constructed - // Sadly, that will throw an exception on enumeration instead of just completing. - // So it is NOT safe to just cast to object to IEnumerable and party on - it might throw! - private static bool IsDefaultImmutable( object? o ) - { - if( o is null ) - { - return false; - } - - // This applies to a pattern of types that implement the IsDefault - // property. The most common is ImmutableArray but there are many - // others. This will skip all the generic type cruft and try to get - // the common property - if it isn't there. Then it's not one of the - // types to care about for this check. - PropertyInfo? propInfo = o.GetType().GetProperty("IsDefault"); - if( propInfo is null) - { - return false; - } - - object? propVal = propInfo.GetValue(o); - return propVal != null - && propVal is bool propBoolVal - && propBoolVal; - } - } -} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/SequenceComparer.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/SequenceComparer.cs new file mode 100644 index 0000000..0ed32ef --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/SequenceComparer.cs @@ -0,0 +1,56 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Comparer to test an array (element by element) for equality + /// Type of the elements of a sequence + public class SequenceComparer + : IEqualityComparer> + { + /// Initializes a new instance of the class. + /// Comparer to use in comparing individual items; Default is + public SequenceComparer( IEqualityComparer? comparer = null ) + { + ItemComparer = comparer ?? EqualityComparer.Default; + } + + /// + [SuppressMessage( "StyleCop.CSharp.NamingRules", "SA1305:Field names should not use Hungarian notation", Justification = "xValues and yValues are not Hungarian names" )] + public bool Equals( IEnumerable? x, IEnumerable? y ) + { + ArgumentNullException.ThrowIfNull( x ); + ArgumentNullException.ThrowIfNull( y ); + + var xValues = x.ToImmutableArray(); + var yValues = x.ToImmutableArray(); + return xValues.Length == yValues.Length + && xValues.Zip( yValues, ( a, b ) => ItemComparer.Equals( a, b ) ).All( x => x ); + } + + /// + /// + /// Signature of interface requires non-null values for so + /// compilation should complain if nullability analysis is applied. However, this + /// implementation is tolerant of null following common practice and turns that + /// into a 0 hash code. + /// + public int GetHashCode( [DisallowNull] IEnumerable obj ) + { + return obj is null ? 0 : obj.GetHashCode(); + } + + /// Gets the item comparer for comparing individual items + /// The default comparer is + public IEqualityComparer ItemComparer { get; init; } = EqualityComparer.Default; + + /// Default constructed comparer. + public static readonly SequenceComparer Default = new(EqualityComparer.Default); + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj b/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj index 5108815..f423851 100644 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj @@ -1,7 +1,7 @@  - net10.0;net9.0 + net10.0 enable @@ -23,12 +23,10 @@ + - - - diff --git a/src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/UniDiffExtensions.cs similarity index 92% rename from src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs rename to src/Ubiquity.NET.SourceGenerator.Test.Utils/UniDiffExtensions.cs index 8af12f4..6c05f43 100644 --- a/src/Ubiquity.NET.CommandLine.SrcGen.UT/UniDiffExtensions.cs +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/UniDiffExtensions.cs @@ -3,7 +3,9 @@ using DiffPlex.Renderer; -namespace Ubiquity.NET.CommandLine.SrcGen.UT +using Microsoft.CodeAnalysis.Text; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils { internal static class UniDiffExtensions { diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/VerifierExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/VerifierExtensions.cs new file mode 100644 index 0000000..15897cd --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/VerifierExtensions.cs @@ -0,0 +1,363 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Utility class to implement extensions for + [SuppressMessage( "Design", "CA1034: Nested types should not be visible", Justification = "Bogus; extension see: https://github.com/dotnet/sdk/issues/51681, and https://github.com/dotnet/roslyn-analyzers/issues/7765" )] + public static class VerifierExtensions + { + extension( IVerifier Verify ) + { + /// Verifies a collection has a specific count + /// Type of elements + /// Expected count of the elements in the collection + /// Collection to test + /// Message to include in assertions + /// Expression for the collection; Normally provided by the compiler + public void HasCount( + int expected, + IEnumerable collection, + string? message = null, + [CallerArgumentExpression( nameof( collection ) )] string collectionExpression = "" ) + { + int count = collection.Count(); + Verify.Equal( expected, count, message ?? $"{collectionExpression} [Count: {count}] does not have expected count '{expected}'" ); + } + + /// Verifies if is NOT an instance of the type specified by + /// Value to test + /// Type to validate against + /// Message to use; If none provided a default message is used + /// Expression for the ; Normally provided by the compiler + public void IsNotInstanceOfType( + object? value, + Type wrongType, + string? message = "", + [CallerArgumentExpression( nameof( value ) )] string valueExpression = "" + ) + { + ArgumentNullException.ThrowIfNull( wrongType ); + + bool isInstanceOfType = value != null && wrongType.IsInstanceOfType( value ); + + // If it is an instance of the specified type then fail the test + if(isInstanceOfType) + { + if(string.IsNullOrEmpty( message )) + { + Verify.Fail( $"'{valueExpression}' is an instance of type '{wrongType}'" ); + } + else + { + Verify.Fail( message ); + } + } + } + + /// Verifies if is NOT an instance of the type specified by + /// Value to test + /// Message to use; If none provided a default message is used + /// Expression for the ; Normally provided by the compiler + public void IsNotInstanceOfType( + object? value, + string? message = "", + [CallerArgumentExpression( nameof( value ) )] string valueExpression = "" + ) + { + Verify.IsNotInstanceOfType( value, typeof( T ), message, valueExpression ); + } + + /// Verifies if is an instance of the type specified by + /// Value to test + /// Type to validate against + /// Message to use; If none provided a default message is used + /// Expression for the ; Normally provided by the compiler + public void IsInstanceOfType( object? value, Type expectedType, string? message = "", [CallerArgumentExpression( nameof( value ) )] string valueExpression = "" ) + { + ArgumentNullException.ThrowIfNull( expectedType ); + + bool isInstanceOfType = value != null && expectedType.IsInstanceOfType( value ); + + // If it is NOT an instance of the specified type then fail the test + if(!isInstanceOfType) + { + if(string.IsNullOrEmpty( message )) + { + Verify.Fail( $"'{valueExpression}' is not an instance of type '{expectedType}'" ); + } + else + { + Verify.Fail( message ); + } + } + } + + /// Verifies if is NOT an instance of the type specified by + /// Value to test + /// Message to use; If none provided a default message is used + /// Expression for the ; Normally provided by the compiler + public void IsInstanceOfType( + object? value, + string? message = "", + [CallerArgumentExpression( nameof( value ) )] string valueExpression = "" + ) + { + Verify.IsInstanceOfType( value, typeof( T ), message, valueExpression ); + } + + /// Validates two are equivalent + /// Results of first run + /// Results of second run + /// Names of custom tracking steps to validate + public void AreEqual( + GeneratorDriverRunResult r1, + GeneratorDriverRunResult r2, + ImmutableArray trackingNames + ) + { + var trackedSteps1 = r1.GetTrackedSteps(trackingNames); + var trackedSteps2 = r2.GetTrackedSteps(trackingNames); + + // Assert the static requirements + Verify.False( trackedSteps1.Count == 0, "Should not be an empty set of steps matching tracked names" ); + Verify.Equal( trackedSteps1.Count, trackedSteps2.Count, "Both runs should have same number of tracked steps" ); + bool hasSameKeys = trackedSteps1.Zip(trackedSteps2, ( s1, s2 ) => trackedSteps2.ContainsKey(s1.Key) && trackedSteps1.ContainsKey(s2.Key)) + .All(x => x); + + Verify.True( hasSameKeys, "Both sets of runs should have the same keys" ); + + // loop through all KVPs of name to step in result set 1 + // assert that the second run steps for the same tracking name are equal. + foreach(var (trackingName, runSteps1) in trackedSteps1) + { + var runSteps2 = trackedSteps2[trackingName]; + Verify.AreEqual( runSteps1, runSteps2, trackingName ); + } + } + + /// Verifies each member of a pair of are equivalent. + /// Array of steps to test against + /// Array of steps to assert are equal to the elements of + /// Tracking name of the step for use in diagnostic messages + /// + /// Each item is tested for equality and this only passes if ALL members are equal. + /// + public void AreEqual( + ImmutableArray steps1, + ImmutableArray steps2, + string stepTrackingName + ) + { + Verify.HasCount( steps1.Length, steps2, "Step lengths should be equal" ); + for(int i = 0; i < steps1.Length; ++i) + { + var runStep1 = steps1[i]; + var runStep2 = steps2[i]; + + IEnumerable outputs1 = runStep1.Outputs.Select(x => x.Value); + IEnumerable outputs2 = runStep2.Outputs.Select(x => x.Value); + Verify.SequenceEqual( outputs1, outputs2, EqualityComparer.Default, $"{stepTrackingName} should produce cacheable outputs" ); + Verify.OutputsCachedOrUnchanged( runStep2, stepTrackingName ); + Verify.ObjectGraphContainsValidSymbols( runStep1, stepTrackingName ); + } + } + + /// Verifies that all of the tracked output steps are cached + /// Run results to test for cached outputs + public void Cached( GeneratorDriverRunResult driverRunResult ) + { + // verify the second run only generated cached source outputs + var uncachedSteps = from generatorRunResult in driverRunResult.Results + from trackedStepKvp in generatorRunResult.TrackedOutputSteps + from runStep in trackedStepKvp.Value // name is used in select if condition passes + from valueReasonTuple in runStep.Outputs // all outputs must have a cached reason. + where valueReasonTuple.Reason != IncrementalStepRunReason.Cached + select runStep.Name; + + foreach(string stepTrackingName in uncachedSteps) + { + Verify.Fail( $"Step name {stepTrackingName ?? ""} contains uncached results for second run!" ); + } + } + + /// Validates that an object is not of a banned type + /// object node to test + /// reason message for any failures + /// parameters for construction of any exceptions + public void NotBannedType( + object? node, + [StringSyntax( StringSyntaxAttribute.CompositeFormat )] string message, + params string[] parameters + ) + { + // can't validate anything for the type of a null + if(node is not null) + { + string msg = string.Format(CultureInfo.CurrentCulture, message, parameters); + + // While this is not a comprehensive list. it covers the most common mistakes directly + Verify.IsNotInstanceOfType( node, msg ); + Verify.IsNotInstanceOfType( node, msg ); + Verify.IsNotInstanceOfType( node, msg ); + } + } + + /// Validates that all are either or + /// Step of the run to test + /// Tracking name to use in assertion messages on failures + public void OutputsCachedOrUnchanged( + IncrementalGeneratorRunStep runStep, + string stepTrackingName + ) + { + Verify.False( + runStep.Outputs.Any( x => x.Reason != IncrementalStepRunReason.Cached && x.Reason != IncrementalStepRunReason.Unchanged ), + $"{stepTrackingName} should have only cached or unchanged reasons!" + ); + } + + /// Validates that the output of a doesn't use any banned types. + /// Run step to validate + /// Name of the step to aid in diagnostics + /// + /// + /// It is debatable if this should be used in a test or an analyzer for generators. In a test + /// it is easy to omit from the tests (or not test at all in early development cycles). + /// An analyzer can operate as you type code in the editor or when you compile the code so + /// has a greater chance of catching erroneous use. Unfortunately no such analyzer exists + /// as of yet. [It's actually hard to define the rules an analyzer should follow]. So this + /// will do the best it can for now... + /// + public void ObjectGraphContainsValidSymbols( + IncrementalGeneratorRunStep runStep, + string stepTrackingName + ) + { + // Including the stepTrackingName in error messages to make it easier to isolate issues + const string because = "Step shouldn't contain banned symbols or non-equatable types. [{0}; {1}]"; + var visited = new HashSet(); + + // Check all of the outputs - probably overkill, but why not + foreach(var (obj, _) in runStep.Outputs) + { + Visit( obj, visited, because, stepTrackingName, runStep.Name ?? "" ); + } + + // Private function to recursively validate an object is cacheable + void Visit( + object? node, + HashSet visitedNodes, + [StringSyntax( StringSyntaxAttribute.CompositeFormat )] string message, + params string[] parameters + ) + { + // If we've already seen this object, or it's null, stop. + if(node is null || !visitedNodes.Add( node )) + { + return; + } + + Verify.NotBannedType( node, message, parameters ); + + // Skip basic types and anything equatable, this includes + // any equatable collections such as EquatableArray as + // that implies all elements are equatable already. + // For now equatable type skipping is disabled as testing for + // that is complex... + Type type = node.GetType(); + if(type.IsBasicType() /*|| type.IsEquatable()*/) + { + return; + } + + // If the object is a collection, check each of the values + if(node is IEnumerable collection and not string && !IsDefaultImmutable( node )) + { + foreach(object element in collection) + { + // recursively check each element in the collection + Visit( element, visitedNodes, message, parameters ); + } + } + else + { + // Recursively check each field in the object + foreach(FieldInfo field in type.GetFields( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance )) + { + object? fieldValue = field.GetValue(node); + Visit( fieldValue, visitedNodes, message, parameters ); + } + } + } + } + + /// Assert that the contents of two instances are valid + /// Expected text + /// Actual text to compare + /// Message to include as a prefix to any differences found (default: empty string) + /// + /// This will detect differences between and and + /// will report the differences found in any assertion triggered. + /// + public void AreEqual( + SourceText expected, + SourceText actual, + string? message = null + ) + { + message ??= string.Empty; + + // if message is not empty make sure it ends in a newline + if(!string.IsNullOrEmpty( message ) && message.EndsWith( '\n' )) + { + message = $"{message}\n"; + } + + string uniDiff = expected.UniDiff(actual); + Verify.True( string.IsNullOrWhiteSpace( uniDiff ), $"{message}Differences:\n{uniDiff}" ); + } + } + + // This prevents visiting an Immutable collection that is default constructed + // Sadly, that will throw an exception on enumeration instead of just completing. + // So it is NOT safe to just cast to IEnumerable and party on - it might throw! + private static bool IsDefaultImmutable( object? o ) + { + if(o is null) + { + return false; + } + + // This applies to a pattern of types that implement the IsDefault + // property. The most common is ImmutableArray but there are many + // others. This will skip all the generic type cruft and try to get + // the common property - if it isn't there. Then it's not one of the + // types to care about for this check. + PropertyInfo? propInfo = o.GetType().GetProperty("IsDefault"); + if(propInfo is null) + { + return false; + } + + object? propVal = propInfo.GetValue(o); + return propVal != null + && propVal is bool propBoolVal + && propBoolVal; + } + } +} diff --git a/src/Ubiquity.NET.SrcGeneration.UT/Ubiquity.NET.SrcGeneration.UT.csproj b/src/Ubiquity.NET.SrcGeneration.UT/Ubiquity.NET.SrcGeneration.UT.csproj index e31676d..d14633a 100644 --- a/src/Ubiquity.NET.SrcGeneration.UT/Ubiquity.NET.SrcGeneration.UT.csproj +++ b/src/Ubiquity.NET.SrcGeneration.UT/Ubiquity.NET.SrcGeneration.UT.csproj @@ -13,6 +13,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj b/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj index 0a11288..03523ca 100644 --- a/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj +++ b/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj @@ -38,7 +38,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Ubiquity.NET.Utils.slnx b/src/Ubiquity.NET.Utils.slnx index 8e264a8..409350d 100644 --- a/src/Ubiquity.NET.Utils.slnx +++ b/src/Ubiquity.NET.Utils.slnx @@ -18,7 +18,7 @@ - +