Skip to content

Commit e4d126e

Browse files
Amaury LevéCopilot
andcommitted
AotReflection: capture assembly-level attributes
Adds an AssemblyAttributes property to the emitted MSTestReflectionMetadata registry containing all attributes declared with [assembly: ...] in the same compilation. The attribute payload is built via the existing AttributeApplicationModel pipeline (reused from class/method attribute emission), so adapters can iterate without calling Assembly.GetCustomAttributes at runtime. Part of #1837. Stacked on #9004, #9005, #9006. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 45911c8 commit e4d126e

4 files changed

Lines changed: 179 additions & 4 deletions

File tree

src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,27 @@ node is TypeDeclarationSyntax type
4545
.Where(static model => model is not null)
4646
.Select(static (model, _) => model!);
4747

48-
IncrementalValueProvider<(string? AssemblyName, ImmutableArray<TestClassModel> Classes)> combined =
48+
// Pull assembly-level attributes from the compilation (one value per run) and
49+
// wrap them in an equatable model so this branch of the pipeline can stay cached
50+
// when only test-class code changes.
51+
IncrementalValueProvider<AssemblyMetadataModel> assemblyMetadata =
52+
context.CompilationProvider.Select(static (c, ct) =>
53+
{
54+
ct.ThrowIfCancellationRequested();
55+
return new AssemblyMetadataModel(
56+
TestClassModelBuilder.BuildAttributes(c.Assembly.GetAttributes()));
57+
});
58+
59+
IncrementalValueProvider<(string? AssemblyName, AssemblyMetadataModel Metadata, ImmutableArray<TestClassModel> Classes)> combined =
4960
context.CompilationProvider.Select(static (c, _) => c.AssemblyName)
50-
.Combine(testClasses.Collect());
61+
.Combine(assemblyMetadata)
62+
.Combine(testClasses.Collect())
63+
.Select(static (tuple, _) => (tuple.Left.Left, tuple.Left.Right, tuple.Right));
5164

5265
context.RegisterImplementationSourceOutput(combined, static (ctx, payload) =>
5366
{
5467
string assemblyName = payload.AssemblyName ?? "Unknown";
55-
string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Classes);
68+
string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Metadata, payload.Classes);
5669
ctx.AddSource("MSTestReflectionMetadata.Registry.g.cs", SourceText.From(source, Encoding.UTF8));
5770
});
5871
}

src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public static string EmitSupportTypes()
8383
return sb.ToString();
8484
}
8585

86-
public static string EmitRegistry(string assemblyName, IReadOnlyList<TestClassModel> testClasses)
86+
public static string EmitRegistry(string assemblyName, AssemblyMetadataModel assemblyMetadata, IReadOnlyList<TestClassModel> testClasses)
8787
{
8888
var sb = new IndentedStringBuilder();
8989
AppendHeader(sb);
@@ -99,6 +99,12 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList<TestClassMo
9999
{
100100
sb.AppendLine($"public const string AssemblyName = \"{Escape(assemblyName)}\";");
101101
sb.AppendLine();
102+
103+
// Emit assembly-level [assembly: ...] attributes so the consumer never has to call
104+
// Assembly.GetCustomAttributes for attributes declared in the same compilation.
105+
EmitAssemblyAttributesProperty(sb, assemblyMetadata.Attributes);
106+
sb.AppendLine();
107+
102108
sb.AppendLine("public static IReadOnlyList<TestClassReflectionInfo> TestClasses { get; } = new TestClassReflectionInfo[]");
103109
using (sb.Block(null))
104110
{
@@ -119,6 +125,35 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList<TestClassMo
119125
return sb.ToString();
120126
}
121127

128+
private static void EmitAssemblyAttributesProperty(IndentedStringBuilder sb, EquatableArray<AttributeApplicationModel> attributes)
129+
{
130+
if (attributes.Length == 0)
131+
{
132+
sb.AppendLine("public static IReadOnlyList<Attribute> AssemblyAttributes { get; } = Array.Empty<Attribute>();");
133+
return;
134+
}
135+
136+
sb.AppendLine("public static IReadOnlyList<Attribute> AssemblyAttributes { get; } = new Attribute[]");
137+
using (sb.Block(null))
138+
{
139+
for (int i = 0; i < attributes.Length; i++)
140+
{
141+
AttributeApplicationModel attr = attributes[i];
142+
sb.Append(BuildAttributeExpression(attr));
143+
if (i < attributes.Length - 1)
144+
{
145+
sb.AppendLine(",");
146+
}
147+
else
148+
{
149+
sb.AppendLine();
150+
}
151+
}
152+
}
153+
154+
sb.AppendLine(";");
155+
}
156+
122157
private static void EmitTestClass(IndentedStringBuilder sb, TestClassModel model)
123158
{
124159
string fqn = model.FullyQualifiedTypeName;

src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ internal sealed record TestPropertyModel(
6060
internal sealed record TestConstructorModel(
6161
EquatableArray<TestParameterModel> Parameters);
6262

63+
/// <summary>
64+
/// Assembly-scoped metadata captured at compile time so the consumer never has to call
65+
/// <see cref="System.Reflection.Assembly.GetCustomAttributes(System.Type, bool)"/> for
66+
/// attributes declared with <c>[assembly: ...]</c> in the same compilation.
67+
/// </summary>
68+
internal sealed record AssemblyMetadataModel(
69+
EquatableArray<AttributeApplicationModel> Attributes);
70+
6371
internal sealed record TestClassModel(
6472
string FullyQualifiedTypeName,
6573
string ContainingNamespace,

test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ public class TestInitializeAttribute : System.Attribute { }
5151
5252
[System.AttributeUsage(System.AttributeTargets.Method)]
5353
public class TestCleanupAttribute : System.Attribute { }
54+
55+
[System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
56+
public class ParallelizeAttribute : System.Attribute
57+
{
58+
public int Workers { get; set; }
59+
public string? Scope { get; set; }
60+
}
5461
}
5562
""";
5663

@@ -764,6 +771,118 @@ public void Test() { }
764771
registry.Should().NotContain("Name = \"GetType\"");
765772
}
766773

774+
[TestMethod]
775+
public void Generator_CapturesAssemblyLevelAttribute()
776+
{
777+
const string userCode = """
778+
using Microsoft.VisualStudio.TestTools.UnitTesting;
779+
780+
[assembly: Parallelize(Workers = 4, Scope = "Method")]
781+
782+
namespace Sample
783+
{
784+
[TestClass]
785+
public class Tests
786+
{
787+
[TestMethod]
788+
public void Test() { }
789+
}
790+
}
791+
""";
792+
793+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
794+
795+
result.Diagnostics.Should().BeEmpty();
796+
string registry = GetRegistry(result);
797+
registry.Should().Contain("public static IReadOnlyList<Attribute> AssemblyAttributes { get; } = new Attribute[]");
798+
registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()");
799+
registry.Should().Contain("Workers = 4");
800+
registry.Should().Contain("Scope = \"Method\"");
801+
}
802+
803+
[TestMethod]
804+
public void Generator_AssemblyAttributes_IsEmptyArray_WhenNoneApplied()
805+
{
806+
const string userCode = """
807+
using Microsoft.VisualStudio.TestTools.UnitTesting;
808+
809+
namespace Sample
810+
{
811+
[TestClass]
812+
public class Tests
813+
{
814+
[TestMethod]
815+
public void Test() { }
816+
}
817+
}
818+
""";
819+
820+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
821+
822+
result.Diagnostics.Should().BeEmpty();
823+
string registry = GetRegistry(result);
824+
registry.Should().Contain("public static IReadOnlyList<Attribute> AssemblyAttributes { get; } = Array.Empty<Attribute>();");
825+
registry.Should().NotContain("public static IReadOnlyList<Attribute> AssemblyAttributes { get; } = new Attribute[]");
826+
}
827+
828+
[TestMethod]
829+
public void Generator_CapturesMultipleAssemblyAttributes_InDeclarationOrder()
830+
{
831+
const string userCode = """
832+
using Microsoft.VisualStudio.TestTools.UnitTesting;
833+
834+
[assembly: Parallelize(Workers = 1)]
835+
[assembly: Parallelize(Workers = 2)]
836+
[assembly: Parallelize(Workers = 3)]
837+
838+
namespace Sample
839+
{
840+
[TestClass]
841+
public class Tests
842+
{
843+
[TestMethod]
844+
public void Test() { }
845+
}
846+
}
847+
""";
848+
849+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
850+
851+
result.Diagnostics.Should().BeEmpty();
852+
string registry = GetRegistry(result);
853+
854+
int idx1 = registry.IndexOf("Workers = 1", StringComparison.Ordinal);
855+
int idx2 = registry.IndexOf("Workers = 2", StringComparison.Ordinal);
856+
int idx3 = registry.IndexOf("Workers = 3", StringComparison.Ordinal);
857+
858+
idx1.Should().BeGreaterThan(-1);
859+
idx2.Should().BeGreaterThan(idx1);
860+
idx3.Should().BeGreaterThan(idx2);
861+
}
862+
863+
[TestMethod]
864+
public void Generator_AssemblyAttributes_AreEmittedEvenWithNoTestClasses()
865+
{
866+
const string userCode = """
867+
using Microsoft.VisualStudio.TestTools.UnitTesting;
868+
869+
[assembly: Parallelize(Workers = 8)]
870+
871+
namespace Sample
872+
{
873+
public class NotATest { }
874+
}
875+
""";
876+
877+
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
878+
879+
result.Diagnostics.Should().BeEmpty();
880+
string registry = GetRegistry(result);
881+
registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()");
882+
registry.Should().Contain("Workers = 8");
883+
registry.Should().NotContain("new TestClassReflectionInfo(");
884+
}
885+
767886
private static string GetRegistry(GeneratorRunResult result)
768887
=> result.GeneratedSources
769888
.Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs")

0 commit comments

Comments
 (0)