// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface;
namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration;
///
/// Source-generator-backed implementation of .
///
///
///
/// Scope of the source-generated data. The shipping MSTest source generator's
/// rooting-mode [ModuleInitializer] calls
///
/// with the assembly, [TestClass] types, and their [TestMethod]-annotated
/// s. The reflection-free generator additionally publishes complete
/// pre-materialized type/method attributes, assembly attributes, and direct invocation delegates
/// via the richer
///
/// family of overloads. Missing entries—including unresolved members and incompletely materialized
/// attribute sets—fall back to runtime reflection. For complete async methods, registration keeps
/// generated declared attributes authoritative and performs a targeted runtime merge of
/// compiler-special and
/// metadata. Reflection-free mode emits constructor
/// invocation and applicable property-setter delegates, but general constructor/property
/// enumeration and lookup remain reflective; navigation data remains unpopulated.
/// MethodInfo keys (and PropertyInfo keys for emitted setters) are resolved with bounded reflection
/// once during module initialization so they remain compatible with existing adapter contracts.
///
///
/// Why a fallback exists at all. Each fallback in this class falls into one of three
/// categories. Cataloguing them here so that adding a new fallback is a deliberate choice,
/// not an accidental hidden one:
///
///
///
///
/// Category A — Generator-gap fallback. The corresponding field on
/// may not be populated in rooting mode,
/// for skipped/unresolved members, or when compile-time materialization is incomplete. A
/// dictionary hit is authoritative even when its value is empty; only a miss falls back.
/// Today this covers type/method misses,
/// assembly attributes in rooting mode, ,
/// , ,
/// , and unmatched requests.
///
///
///
///
/// Category B — Contract-mismatch fallback. The interface returns "every method"
/// (or similar), but the source generator intentionally models only test methods. Always
/// delegating is correct here; the only way to avoid the fallback would be to either
/// change the contract or to enumerate all methods at compile time (which is exactly
/// what reflection already does at runtime). Today this covers
/// , , and
/// .
///
///
///
///
/// Category C — Cross-assembly fallback. The lookup targets an assembly that did
/// not participate in source generation (test framework, adapter, extensions, or assets
/// packed without the generator). No amount of generator work eliminates this — assemblies
/// we do not compile cannot register source-gen data. Today this covers
/// , the no-match branch of ,
/// , and the non-/non-
/// branch of .
///
///
///
///
/// Trim/AOT safety. Reflection-free mode directly references modeled attributes and
/// invocation targets; both modes use member-scoped [DynamicDependency] roots for each
/// [TestClass] and accessible base type. Nested types are deliberately excluded from those
/// roots. Reflection fallback remains for unsupported, unresolved, cross-assembly, and general
/// enumeration operations.
///
///
/// Adding a new fallback? Mark the method with a // Category A/B/C comment and
/// explain which one and why. The blind-corner risk is fallbacks that look intentional but
/// were really just oversights — labelling each call site makes the design choice visible.
///
///
internal sealed class SourceGeneratedReflectionOperations : IReflectionOperations
{
private readonly ConcurrentDictionary _attributeCache = [];
private readonly ReflectionOperations _fallback = new();
public SourceGeneratedReflectionOperations(SourceGeneratedReflectionDataProvider dataProvider)
=> DataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider));
internal SourceGeneratedReflectionDataProvider DataProvider { get; }
// Category A for Type / MethodInfo misses. Reflection-free mode emits complete entries when
// possible; rooting mode and unresolved/incomplete entries intentionally fall back.
// Category C for every other MemberInfo subtype — the source generator never models
// FieldInfo, EventInfo, ParameterInfo, etc. and we have no plans to.
[return: NotNullIfNotNull(nameof(memberInfo))]
public object[]? GetCustomAttributes(MemberInfo memberInfo)
=> memberInfo switch
{
null => null,
Type type => GetTypeAttributes(type),
MethodInfo method => GetMethodAttributes(method),
_ => _fallback.GetCustomAttributes(memberInfo),
};
// Category A: reflection-free mode emits materializable assembly attributes; rooting mode
// and an empty generated set fall through to preserve the existing filtered lookup behavior.
public object[] GetCustomAttributes(Assembly assembly, Type type)
{
object[] sourceGenAttributes = DataProvider.GetAssemblyAttributes(assembly);
return sourceGenAttributes.Length == 0
? _fallback.GetCustomAttributes(assembly, type)
: [.. sourceGenAttributes.Where(type.IsInstanceOfType)];
}
// Category A: TypeConstructors is not populated by today's emitter.
public ConstructorInfo[] GetDeclaredConstructors(Type classType)
{
SourceGeneratedReflectionDataProvider data = DataProvider.GetSnapshot();
return data.TypeConstructors.TryGetValue(classType, out ConstructorInfo[]? constructors)
? constructors
: _fallback.GetDeclaredConstructors(classType);
}
// Category B: the source-generated TypeMethods dictionary is partial — today it only
// contains methods annotated with [TestMethod] (and inherited [TestMethod]s), so it
// cannot satisfy the GetDeclaredMethods contract which is expected to return every
// method declared on the type. Always delegate to the runtime fallback to preserve
// correctness; the source-generated data is still used elsewhere (e.g. attribute
// lookup) to avoid reflection at runtime.
public MethodInfo[] GetDeclaredMethods(Type classType)
=> _fallback.GetDeclaredMethods(classType);
// Category A: TypeProperties is not populated by today's emitter.
public PropertyInfo[] GetDeclaredProperties(Type type)
{
SourceGeneratedReflectionDataProvider data = DataProvider.GetSnapshot();
return data.TypeProperties.TryGetValue(type, out PropertyInfo[]? properties)
? properties
: _fallback.GetDeclaredProperties(type);
}
// Category C: assemblies that did not opt into source generation (typically the test
// framework, the adapter, MSTest extensions, or test assets packed without the
// generator) have no entries in `data.Types`. Fall back so those assemblies behave the
// same as in non-source-gen mode.
public Type[] GetDefinedTypes(Assembly assembly)
{
SourceGeneratedReflectionDataProvider data = DataProvider.GetSnapshot();
Type[] filtered = [.. data.Types.Where(t => t.Assembly.Equals(assembly))];
return filtered.Length > 0 ? filtered : _fallback.GetDefinedTypes(assembly);
}
// Category B: the source-generated TypeMethods dictionary is partial (only
// [TestMethod]-annotated methods, no generics or by-ref) and the runtime contract
// requires returning every runtime method. Always delegate to the fallback.
public MethodInfo[] GetRuntimeMethods(Type type) => _fallback.GetRuntimeMethods(type);
// Category B: the source-generator does not pre-resolve arbitrary methods (the
// partial TypeMethods dictionary only carries [TestMethod]-annotated entries). Doing
// our own GetRuntimeMethods + parameter-match here would (a) duplicate the scan that
// the fallback already performs on miss and (b) diverge from Type.GetMethod's binder
// semantics (overload resolution, generic / by-ref handling). Delegate so callers get
// the same selection rules as reflection mode.
public MethodInfo? GetRuntimeMethod(Type declaringType, string methodName, Type[] parameters, bool includeNonPublic)
=> _fallback.GetRuntimeMethod(declaringType, methodName, parameters, includeNonPublic);
// Category A: TypePropertiesByName is not populated by today's emitter.
public PropertyInfo? GetRuntimeProperty(Type classType, string propertyName, bool includeNonPublic)
{
SourceGeneratedReflectionDataProvider data = DataProvider.GetSnapshot();
if (data.TypePropertiesByName.TryGetValue(classType, out Dictionary? properties)
&& properties.TryGetValue(propertyName, out PropertyInfo? property))
{
if (!includeNonPublic)
{
bool isPublic = property.GetMethod?.IsPublic is true || property.SetMethod?.IsPublic is true;
if (!isPublic)
{
return null;
}
}
return property;
}
return _fallback.GetRuntimeProperty(classType, propertyName, includeNonPublic);
}
// Category C: `Type.GetType(string)` only resolves assembly-qualified names (or types
// in the calling assembly / mscorlib). The source-generated `TypesByName` dictionary is
// keyed by full type name without assembly qualification and aggregates entries across
// every registered test assembly, so consulting it here could silently bind to a
// same-named type from the wrong assembly. Match the runtime contract by always
// delegating; callers that have an assembly in hand use the `GetType(Assembly, string)`
// overload below for source-generated lookups.
public Type? GetType(string typeName)
=> _fallback.GetType(typeName);
// Category C: assemblies that did not opt into source generation (and types in
// opted-in assemblies that the generator skipped, such as open generics) are not in
// TypesByName; fall back so cross-assembly lookups still resolve. The composite routes
// this through the per-provider TryGetTypeByName override so two assemblies with the
// same fully-qualified type name do not shadow each other.
public Type? GetType(Assembly assembly, string typeName)
=> DataProvider.TryGetTypeByName(assembly, typeName, out Type? type)
? type
: _fallback.GetType(assembly, typeName);
// Category A: reflection-free mode populates constructor invocation delegates. This is
// distinct from TypeConstructors, which remains empty because general ConstructorInfo
// enumeration is deferred. Rooting mode, unsupported types, and non-matching argument
// shapes fall back to reflection.
public object? CreateInstance(Type type, object?[] parameters)
{
SourceGeneratedReflectionDataProvider data = DataProvider.GetSnapshot();
return data.TypeConstructorsInvoker.TryGetValue(type, out SourceGeneratedReflectionDataProvider.ConstructorInvoker[]? invokers)
&& TryInvokeMatchingConstructor(invokers, parameters, out object? instance)
? instance
: _fallback.CreateInstance(type, parameters);
}
// Returns a delegate that constructs `type` via the source-generated constructor invoker,
// or null when the generator did not register the type (caller falls back to reflection).
// The delegate routes through CreateInstance so a no-match still falls back to reflection
// (Activator.CreateInstance) instead of throwing.
public Func