// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Diagnostics; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Models; using MSTest.Analyzers.Shared; namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Generators; /// /// Translates a decorated with [TestClass] into an /// immutable, equatable the emitter can consume. /// /// /// This type owns the top-level orchestration — walking the inheritance chain and assembling the model — /// and delegates the specialized subsystems to focused helpers: /// /// resolves [DynamicData] sources. /// decides which attributes survive trimming and converts them to models. /// provides the reusable accessibility / referenceability predicates. /// /// internal static class TestClassModelBuilder { private const string AsyncStateMachineAttributeName = "global::System.Runtime.CompilerServices.AsyncStateMachineAttribute"; private const string DebuggerStepThroughAttributeName = "global::System.Diagnostics.DebuggerStepThroughAttribute"; private const string TestClassAttributeName = "global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute"; private const string TestMethodAttributeName = "global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"; private const string DataRowAttributeName = "global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute"; public static TestClassModel Build(INamedTypeSymbol typeSymbol, List diagnostics) { // Methods / properties are walked across the full inheritance chain (excluding // System.Object) so that MSTest members declared on a base class — // [ClassInitialize], [ClassCleanup], [TestInitialize], [TestCleanup], // [TestMethod], the [TestContext] setter, … — are visible to the consumer // without runtime reflection. // // Iteration order is derived-first. Members declared at a nearer inheritance level // hide same-name ancestor members according to C# lookup rules, while overloads // declared together on the same type are preserved. Indexers are excluded because // their metadata name is not used in C# member access. // Constructors are NEVER inherited and are taken only from the leaf type. var seenPropertyNames = new HashSet(StringComparer.Ordinal); var methodNamesInDerivedTypes = new HashSet(StringComparer.Ordinal); var nonMethodNamesInDerivedTypes = new HashSet(StringComparer.Ordinal); var methodsInDerivedTypes = new List(); ImmutableArray.Builder methods = ImmutableArray.CreateBuilder(); ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); ImmutableArray.Builder baseTypes = ImmutableArray.CreateBuilder(); bool hasUnsupportedTestMethod = false; bool hasPartialTypeInHierarchy = false; string leafFqn = typeSymbol.ToDisplayString(SymbolDisplayFormats.FullyQualified); // Generated registration lives in the leaf type's (the compilation's) assembly, so attribute // materializability is judged from there — even for members inherited from a base type in // another assembly. IAssemblySymbol consumingAssembly = typeSymbol.ContainingAssembly; for (INamedTypeSymbol? current = typeSymbol; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) { bool isLeaf = SymbolEqualityComparer.Default.Equals(current, typeSymbol); hasPartialTypeInHierarchy |= IsPartial(current); ImmutableArray currentMembers = current.GetMembers(); // Capture each closed, referenceable base type so the runtime registration can root // its members (e.g. base-declared [ClassInitialize]/[TestContext]) via [DynamicDependency] // under trimming / Native AOT. Members are folded into the leaf model, but the trimmer // only keeps members of the concrete type unless the base is rooted explicitly too. if (!isLeaf && SymbolReferenceabilityHelper.IsClosedReferenceableType(current, consumingAssembly)) { baseTypes.Add(current.ToDisplayString(SymbolDisplayFormats.FullyQualified)); } foreach (ISymbol member in currentMembers) { switch (member) { case IMethodSymbol { MethodKind: MethodKind.Ordinary } method: ImmutableArray inheritedAttributes = AttributeMaterializationHelper.CollectInheritedAttributes(method); bool isTestMethod = TestMemberValidationHelper.IsTestMethodAttributePresent(inheritedAttributes); bool hiddenByNonMethod = nonMethodNamesInDerivedTypes.Contains(method.Name); bool hiddenByMethodGroup = methodNamesInDerivedTypes.Contains(method.Name); bool isAccessible = TestMemberValidationHelper.IsAccessibleFromConsumer(method, consumingAssembly); if ((hiddenByNonMethod || hiddenByMethodGroup) && isAccessible && TestMemberValidationHelper.TryReportUnsupportedMethod(method, leafFqn, diagnostics)) { hasUnsupportedTestMethod |= isTestMethod; break; } if (hiddenByNonMethod || hiddenByMethodGroup) { hasUnsupportedTestMethod |= isTestMethod && (hiddenByNonMethod || !methodsInDerivedTypes.Any(derivedMethod => ReplacesInheritedRuntimeTest(derivedMethod) && TestMemberValidationHelper.HaveSameRuntimeDiscoverySignature(derivedMethod, method))); break; } if (!isAccessible) { hasUnsupportedTestMethod |= isTestMethod; break; } if (TestMemberValidationHelper.TryReportUnsupportedMethod(method, leafFqn, diagnostics)) { hasUnsupportedTestMethod |= isTestMethod; // Skip generic / by-ref methods entirely so the emitter does not produce // code that references unbound type parameters or ref/in/out arguments. break; } methods.Add(BuildMethod(method, consumingAssembly, inheritedAttributes, isTestMethod)); break; case IPropertySymbol property: hasUnsupportedTestMethod |= HasTestMethodAttribute(property.GetMethod) || HasTestMethodAttribute(property.SetMethod); if (methodNamesInDerivedTypes.Contains(property.Name) || nonMethodNamesInDerivedTypes.Contains(property.Name)) { break; } if (!property.IsIndexer && seenPropertyNames.Add(property.Name) && TestMemberValidationHelper.IsAccessibleFromConsumer(property, consumingAssembly)) { properties.Add(BuildProperty(property, consumingAssembly)); } break; case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: if (TestMemberValidationHelper.TryReportUnsupportedMethod(ctor, leafFqn, diagnostics)) { break; } // MSTest only ever instantiates a test class through a parameterless ctor or a // single-TestContext ctor (the adapter's TypeCache prefers the TestContext ctor and // otherwise takes the parameterless one). Registering any other shape would be dead // — and, because the runtime matches invokers by argument type, an extra compatible // overload (e.g. ctor(object)) could be picked over the intended one. Only emit the // two supported shapes so the type-level lookup stays unambiguous. if (!TestMemberValidationHelper.IsSupportedTestClassConstructor(ctor)) { break; } ctors.Add(new TestConstructorModel(BuildParameters(ctor))); break; case IEventSymbol eventSymbol: hasUnsupportedTestMethod |= HasTestMethodAttribute(eventSymbol.AddMethod) || HasTestMethodAttribute(eventSymbol.RemoveMethod) || HasTestMethodAttribute(eventSymbol.RaiseMethod); break; case IMethodSymbol method: hasUnsupportedTestMethod |= HasTestMethodAttribute(method); break; } } foreach (ISymbol member in currentMembers) { switch (member) { case IMethodSymbol { MethodKind: MethodKind.Ordinary } method: methodNamesInDerivedTypes.Add(method.Name); methodsInDerivedTypes.Add(method); break; case IPropertySymbol { IsIndexer: false }: case IFieldSymbol: case IEventSymbol: case INamedTypeSymbol: nonMethodNamesInDerivedTypes.Add(member.Name); break; } } } AttributeMaterializationHelper.AttributeMaterializationResult classAttributes = AttributeMaterializationHelper.BuildAttributesWithCompleteness( AttributeMaterializationHelper.CollectInheritedAttributes(typeSymbol), consumingAssembly); bool supportsGeneratedDescriptors = classAttributes.IsComplete && classAttributes.Attributes.Length == 1 && classAttributes.Attributes[0].FullyQualifiedAttributeType == TestClassAttributeName; var duplicateTestMethodNames = new HashSet( methods .Where(static method => method.IsTestMethod) .GroupBy(static method => method.Name, StringComparer.Ordinal) .Where(static group => group.Count() > 1) .Select(static group => group.Key), StringComparer.Ordinal); var finalizedMethods = methods .Select(method => duplicateTestMethodNames.Contains(method.Name) ? method with { IsDescriptorSupported = false } : method) .ToImmutableArray(); bool areGeneratedDescriptorsComplete = supportsGeneratedDescriptors && !hasUnsupportedTestMethod && !hasPartialTypeInHierarchy && finalizedMethods.Where(static method => method.IsTestMethod).All(static method => method.IsDescriptorSupported); return new TestClassModel( FullyQualifiedTypeName: leafFqn, ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : typeSymbol.ContainingNamespace.ToDisplayString(), TypeName: typeSymbol.Name, IsAbstract: typeSymbol.IsAbstract, IsStatic: typeSymbol.IsStatic, Constructors: new EquatableArray(ctors.ToImmutable()), Methods: new EquatableArray(finalizedMethods), Properties: new EquatableArray(properties.ToImmutable()), Attributes: classAttributes.Attributes, AreAttributesComplete: classAttributes.IsComplete, SupportsGeneratedDescriptors: supportsGeneratedDescriptors, AreGeneratedDescriptorsComplete: areGeneratedDescriptorsComplete, BaseTypeFullyQualifiedNames: new EquatableArray(baseTypes.ToImmutable())); } private static bool IsPartial(INamedTypeSymbol type) => type.DeclaringSyntaxReferences.Any(static syntaxReference => syntaxReference.GetSyntax().ChildTokens().Any(static token => token.IsKind(SyntaxKind.PartialKeyword))); private static bool HasTestMethodAttribute(IMethodSymbol? method) => method is not null && TestMemberValidationHelper.IsTestMethodAttributePresent(AttributeMaterializationHelper.CollectInheritedAttributes(method)); private static bool ReplacesInheritedRuntimeTest(IMethodSymbol method) => method.OverriddenMethod is not null || (method is { DeclaredAccessibility: Accessibility.Public, IsStatic: false } && HasTestMethodAttribute(method)); private static TestMethodModel BuildMethod( IMethodSymbol method, IAssemblySymbol consumingAssembly, ImmutableArray inheritedAttributes, bool isTestMethod) { ITypeSymbol returnType = method.ReturnType; string returnTypeFqn = returnType.ToDisplayString(SymbolDisplayFormats.FullyQualified); bool returnsTask = returnTypeFqn is "global::System.Threading.Tasks.Task" || returnTypeFqn.StartsWith("global::System.Threading.Tasks.Task<", System.StringComparison.Ordinal); bool returnsValueTask = returnTypeFqn is "global::System.Threading.Tasks.ValueTask" || returnTypeFqn.StartsWith("global::System.Threading.Tasks.ValueTask<", System.StringComparison.Ordinal); bool returnsVoid = returnType.SpecialType == SpecialType.System_Void; ImmutableArray attributesToMaterialize = method.IsAsync ? inheritedAttributes.Where(static attribute => !IsCompilerSpecialAsyncAttribute(attribute)).ToImmutableArray() : inheritedAttributes; AttributeMaterializationHelper.AttributeMaterializationResult methodAttributes = AttributeMaterializationHelper.BuildAttributesWithCompleteness(attributesToMaterialize, consumingAssembly); bool isDescriptorSupported = isTestMethod && methodAttributes.IsComplete && method.DeclaredAccessibility == Accessibility.Public && !method.IsStatic && !method.IsAbstract && !method.IsAsync && returnsVoid && HasOnlyDescriptorSupportedAttributes(methodAttributes.Attributes); return new TestMethodModel( Name: method.Name, DeclaringTypeFullyQualifiedName: method.ContainingType.ToDisplayString(SymbolDisplayFormats.FullyQualified), IsStatic: method.IsStatic, IsAsync: method.IsAsync, ReturnsTask: returnsTask, ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, IsTestMethod: isTestMethod, IsDescriptorSupported: isDescriptorSupported, Parameters: BuildParameters(method), Attributes: methodAttributes.Attributes, AreAttributesComplete: methodAttributes.IsComplete, DynamicDataSources: DynamicDataSourceBuilder.BuildDynamicDataSources(inheritedAttributes, method, consumingAssembly)); } private static bool HasOnlyDescriptorSupportedAttributes(EquatableArray attributes) { int testMethodAttributeCount = 0; foreach (AttributeApplicationModel attribute in attributes) { switch (attribute.FullyQualifiedAttributeType) { case TestMethodAttributeName: testMethodAttributeCount++; break; case DataRowAttributeName: break; default: return false; } } return testMethodAttributeCount == 1; } private static bool IsCompilerSpecialAsyncAttribute(AttributeData attribute) => attribute.AttributeClass?.ToDisplayString(SymbolDisplayFormats.FullyQualified) is AsyncStateMachineAttributeName or DebuggerStepThroughAttributeName; private static TestPropertyModel BuildProperty(IPropertySymbol property, IAssemblySymbol consumingAssembly) => new( Name: property.Name, FullyQualifiedType: property.Type.ToDisplayString(SymbolDisplayFormats.FullyQualified), IsStatic: property.IsStatic, HasGettableValue: property.GetMethod is { } getter && SymbolReferenceabilityHelper.IsMemberAccessibleFrom( getter.DeclaredAccessibility, getter.ContainingType, consumingAssembly), // An init-only setter has public DeclaredAccessibility but cannot be assigned outside an // object initializer, so emitting `instance.Prop = value` would not compile (CS8852); // treat it as non-settable so the adapter falls back to reflection (PropertyInfo.SetValue). HasPublicSetter: property.SetMethod is { DeclaredAccessibility: Accessibility.Public, IsInitOnly: false }, Attributes: AttributeMaterializationHelper.BuildAttributes(AttributeMaterializationHelper.CollectInheritedAttributes(property), consumingAssembly)); private static EquatableArray BuildParameters(IMethodSymbol method) { if (method.Parameters.IsDefaultOrEmpty) { return EquatableArray.Empty; } var parameters = new TestParameterModel[method.Parameters.Length]; for (int i = 0; i < method.Parameters.Length; i++) { IParameterSymbol p = method.Parameters[i]; var namedType = p.Type as INamedTypeSymbol; if (namedType?.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) { namedType = namedType.TypeArguments[0] as INamedTypeSymbol; } string? enumFullyQualifiedType = namedType?.TypeKind == TypeKind.Enum ? namedType.ToDisplayString(SymbolDisplayFormats.FullyQualified) : null; parameters[i] = new TestParameterModel( p.Type.ToDisplayString(SymbolDisplayFormats.FullyQualified), p.Name, enumFullyQualifiedType); } return new EquatableArray(parameters.ToImmutableArray()); } }