Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate and validate derived test classes #3704

Merged
merged 1 commit into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<Nullable>enable</Nullable>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace StyleCop.Analyzers.PrivateAnalyzers
{
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;

[Generator]
internal sealed class DerivedTestGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var testData = context.CompilationProvider.Select((compilation, cancellationToken) =>
{
var currentAssemblyName = compilation.AssemblyName ?? string.Empty;
if (!Regex.IsMatch(currentAssemblyName, @"^StyleCop\.Analyzers\.Test\.CSharp\d+$"))
{
// This is not a test project where derived test classes are expected
return null;
}

var currentVersion = int.Parse(currentAssemblyName["StyleCop.Analyzers.Test.CSharp".Length..]);
var currentTestString = "CSharp" + currentVersion;
var previousTestString = currentVersion switch
{
7 => string.Empty,
_ => "CSharp" + (currentVersion - 1).ToString(),
};
var previousAssemblyName = previousTestString switch
{
"" => "StyleCop.Analyzers.Test",
_ => "StyleCop.Analyzers.Test." + previousTestString,
};

return new TestData(previousTestString, previousAssemblyName, currentTestString, currentAssemblyName);
});

var testTypes = context.CompilationProvider.Combine(testData).SelectMany((compilationAndTestData, cancellationToken) =>
{
var (compilation, testData) = compilationAndTestData;
if (testData is null)
{
return ImmutableArray<string>.Empty;
}

var previousAssembly = compilation.Assembly.Modules.First().ReferencedAssemblySymbols.First(
symbol => symbol.Identity.Name == testData.PreviousAssemblyName);
if (previousAssembly is null)
{
return ImmutableArray<string>.Empty;
}

var collector = new TestClassCollector(testData.PreviousTestString);
var previousTests = collector.Visit(previousAssembly);
return previousTests.ToImmutableArray();
});

context.RegisterSourceOutput(
testTypes.Combine(testData),
(context, testTypeAndData) =>
{
var (testType, testData) = testTypeAndData;
if (testData is null)
{
throw new InvalidOperationException("Not reachable");
}

string expectedTest;
if (testData.PreviousTestString is "")
{
expectedTest = testType.Replace(testData.PreviousAssemblyName, testData.CurrentAssemblyName).Replace("UnitTests", testData.CurrentTestString + "UnitTests");
}
else
{
expectedTest = testType.Replace(testData.PreviousTestString, testData.CurrentTestString);
}

var lastDot = testType.LastIndexOf('.');
var baseNamespaceName = testType["global::".Length..lastDot];
var baseTypeName = testType[(lastDot + 1)..];

lastDot = expectedTest.LastIndexOf('.');
var namespaceName = expectedTest["global::".Length..lastDot];
var typeName = expectedTest[(lastDot + 1)..];
var content =
$@"// <auto-generated/>

#nullable enable

namespace {namespaceName};

using {baseNamespaceName};

public partial class {typeName}
: {baseTypeName}
{{
}}
";

context.AddSource(
typeName + ".cs",
content);
});
}

private sealed record TestData(string PreviousTestString, string PreviousAssemblyName, string CurrentTestString, string CurrentAssemblyName);

private sealed class TestClassCollector : SymbolVisitor<ImmutableSortedSet<string>>
{
private readonly string testString;

public TestClassCollector(string testString)
{
this.testString = testString;
}

public override ImmutableSortedSet<string> Visit(ISymbol? symbol)
=> base.Visit(symbol) ?? throw new InvalidOperationException("Not reachable");

public override ImmutableSortedSet<string>? DefaultVisit(ISymbol symbol)
=> ImmutableSortedSet<string>.Empty;

public override ImmutableSortedSet<string> VisitAssembly(IAssemblySymbol symbol)
{
return this.Visit(symbol.GlobalNamespace);
}

public override ImmutableSortedSet<string> VisitNamespace(INamespaceSymbol symbol)
{
var result = ImmutableSortedSet<string>.Empty;
foreach (var member in symbol.GetMembers())
{
result = result.Union(this.Visit(member)!);
}

return result;
}

public override ImmutableSortedSet<string> VisitNamedType(INamedTypeSymbol symbol)
{
if (this.testString is "")
{
if (symbol.Name.EndsWith("UnitTests"))
{
return ImmutableSortedSet.Create(symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
else
{
return ImmutableSortedSet<string>.Empty;
}
}
else if (symbol.Name.Contains(this.testString))
{
return ImmutableSortedSet.Create(symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
else
{
return ImmutableSortedSet<string>.Empty;
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace StyleCop.Analyzers.PrivateAnalyzers;

using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal sealed class IncludeTestClassesAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Descriptor =
new(PrivateDiagnosticIds.SP0001, "Include all test classes", "Expected test class '{0}' was not found", "Correctness", DiagnosticSeverity.Warning, isEnabledByDefault: true, customTags: new[] { "CompilationEnd" });

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Descriptor);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(context =>
{
var assemblyName = context.Compilation.AssemblyName ?? string.Empty;
if (!Regex.IsMatch(assemblyName, @"^StyleCop\.Analyzers\.Test\.CSharp\d+$"))
{
// This is not a test project where derived test classes are expected
return;
}

// Map actual test class in current project to base type
var testClasses = new ConcurrentDictionary<string, string>();

context.RegisterSymbolAction(
context =>
{
var namedType = (INamedTypeSymbol)context.Symbol;
if (namedType.TypeKind != TypeKind.Class)
{
return;
}

testClasses[namedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)] = namedType.BaseType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? string.Empty;
},
SymbolKind.NamedType);

context.RegisterCompilationEndAction(context =>
{
var currentVersion = int.Parse(assemblyName["StyleCop.Analyzers.Test.CSharp".Length..]);
var currentTestString = "CSharp" + currentVersion;
var previousTestString = currentVersion switch
{
7 => string.Empty,
_ => "CSharp" + (currentVersion - 1).ToString(),
};
var previousAssemblyName = previousTestString switch
{
"" => "StyleCop.Analyzers.Test",
_ => "StyleCop.Analyzers.Test." + previousTestString,
};

var previousAssembly = context.Compilation.Assembly.Modules.First().ReferencedAssemblySymbols.First(
symbol => symbol.Identity.Name == previousAssemblyName);
if (previousAssembly is null)
{
return;
}

var reportingLocation = context.Compilation.SyntaxTrees.FirstOrDefault()?.GetLocation(new TextSpan(0, 0)) ?? Location.None;
var collector = new TestClassCollector(previousTestString);
var previousTests = collector.Visit(previousAssembly);
foreach (var previousTest in previousTests)
{
string expectedTest;
if (previousTestString is "")
{
expectedTest = previousTest.Replace(previousAssemblyName, assemblyName).Replace("UnitTests", currentTestString + "UnitTests");
}
else
{
expectedTest = previousTest.Replace(previousTestString, currentTestString);
}

if (testClasses.TryGetValue(expectedTest, out var actualTest)
&& actualTest == previousTest)
{
continue;
}

context.ReportDiagnostic(Diagnostic.Create(Descriptor, reportingLocation, expectedTest));
}
});
});
}

private sealed class TestClassCollector : SymbolVisitor<ImmutableSortedSet<string>>
{
private readonly string testString;

public TestClassCollector(string testString)
{
this.testString = testString;
}

public override ImmutableSortedSet<string> Visit(ISymbol? symbol)
=> base.Visit(symbol) ?? throw new InvalidOperationException("Not reachable");

public override ImmutableSortedSet<string>? DefaultVisit(ISymbol symbol)
=> ImmutableSortedSet<string>.Empty;

public override ImmutableSortedSet<string> VisitAssembly(IAssemblySymbol symbol)
{
return this.Visit(symbol.GlobalNamespace);
}

public override ImmutableSortedSet<string> VisitNamespace(INamespaceSymbol symbol)
{
var result = ImmutableSortedSet<string>.Empty;
foreach (var member in symbol.GetMembers())
{
result = result.Union(this.Visit(member)!);
}

return result;
}

public override ImmutableSortedSet<string> VisitNamedType(INamedTypeSymbol symbol)
{
if (this.testString is "")
{
if (symbol.Name.EndsWith("UnitTests"))
{
return ImmutableSortedSet.Create(symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
else
{
return ImmutableSortedSet<string>.Empty;
}
}
else if (symbol.Name.Contains(this.testString))
{
return ImmutableSortedSet.Create(symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
else
{
return ImmutableSortedSet<string>.Empty;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace StyleCop.Analyzers.PrivateAnalyzers
{
internal static class PrivateDiagnosticIds
{
/// <summary>
/// SP0001: Include all test classes.
/// </summary>
public const string SP0001 = nameof(SP0001);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<!-- RS2008: Enable analyzer release tracking -->
<NoWarn>$(NoWarn),RS2008</NoWarn>
</PropertyGroup>

<PropertyGroup>
<CodeAnalysisRuleSet>..\StyleCop.Analyzers.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>

<PropertyGroup>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\..\build\keys\StyleCopAnalyzers.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" />
<PackageReference Include="TunnelVisionLabs.LanguageTypes.SourceGenerator" Version="0.1.20-beta" />
<PackageReference Include="TunnelVisionLabs.ReferenceAssemblyAnnotator" Version="1.0.0-alpha.160" PrivateAssets="all" />
<PackageDownload Include="Microsoft.NETCore.App.Ref" Version="[3.1.0]" />
</ItemGroup>

<ItemGroup>
<!-- The .generated file is excluded by default, but we want to show the items in Solution Explorer so we included it as None -->
<None Include="Lightup\.generated\**" />
</ItemGroup>

</Project>