File: Testing\AbstractTestMethodFinder.cs
Web Access
Project: Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Features.Testing;

internal abstract class AbstractTestMethodFinder<TMethodDeclaration>(IEnumerable<ITestFrameworkMetadata> testFrameworks) : ITestMethodFinder where TMethodDeclaration : SyntaxNode
{
    /// <summary>
    /// Output the method symbol as a fully qualified method name, e.g. Namespace.Class.Method to match what test discovery gives us.
    /// Generics are not applicable here - none of our supported test frameworks allow generic test classes / methods.
    /// </summary>
    private static readonly SymbolDisplayFormat s_methodSymbolNoParametersDisplayFormat = new(
        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
        memberOptions: SymbolDisplayMemberOptions.IncludeContainingType);

    protected readonly ImmutableArray<ITestFrameworkMetadata> TestFrameworkMetadata = [.. testFrameworks];

    protected abstract bool IsTestMethod(TMethodDeclaration method);

    protected abstract bool DescendIntoChildren(SyntaxNode node);

    public async Task<ImmutableArray<SyntaxNode>> GetPotentialTestMethodsAsync(
        Document document, TextSpan textSpan, bool useSemanticDiscovery, CancellationToken cancellationToken)
    {
        var testNodes = await GetPotentialTestNodesAsync(document, textSpan, useSemanticDiscovery, cancellationToken).ConfigureAwait(false);

        // Find any test methods that intersect with the requested span.
        var intersectingNodes = testNodes.WhereAsArray(node => node.Span.IntersectsWith(textSpan));
        if (!intersectingNodes.IsEmpty)
        {
            return intersectingNodes;
        }

        // We might have been invoked on a test class.  Check if any of the test method parent nodes intersect with the requested text span.
        return testNodes.WhereAsArray(node => node.Parent?.Span.IntersectsWith(textSpan) == true);
    }

    public bool IsMatch(SemanticModel semanticModel, SyntaxNode node, string fullyQualifiedTestName, CancellationToken cancellationToken)
    {
        var method = (TMethodDeclaration)node;

        // Since discovered tests are not guarantied to run on a particular snapshot, we match optimistically based on test name.
        var methodSymbol = semanticModel.GetRequiredDeclaredSymbol(method, cancellationToken);

        // Do a quicker check to see if the given FQN even contains the method name before doing a full match.
        if (!fullyQualifiedTestName.Contains(methodSymbol.Name))
        {
            return false;
        }

        var fullyQualifiedMethodName = methodSymbol.ToDisplayString(s_methodSymbolNoParametersDisplayFormat);

        // Qualified test names use a '+' to separate outer classes from nested classes whereas display strings use '.'.
        fullyQualifiedTestName = fullyQualifiedTestName.Replace('+', '.');

        // The definition of fully qualified name varies depending on the test framework.
        // For example, XUnit will never include parameters in the FQN it gives to us.
        // However NUnit will give us a FQN with the actual parameter values passed in (e.g. if there's an int parameter, it will pass in the value of the int).
        // To avoid these problems, we compare our method FQN (without parameters) against the test framework FQN with everything past the first open paren removed.
        var indexOfOpenParen = fullyQualifiedTestName.IndexOf('(');
        if (indexOfOpenParen != -1)
        {
            fullyQualifiedTestName = fullyQualifiedTestName.Remove(indexOfOpenParen);
        }

        return fullyQualifiedMethodName == fullyQualifiedTestName;
    }

    public bool IsTestMethod(SyntaxNode node)
        => node is TMethodDeclaration method && IsTestMethod(method);

    public async Task<ImmutableArray<SyntaxNode>> GetSemanticTestMethodsAsync(
        Document document, ImmutableArray<SyntaxNode> nodes, CancellationToken cancellationToken)
    {
        var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
        var (testAttributeTypes, inheritableTestAttributeTypes) = GetTestAttributeTypes(semanticModel.Compilation);

        return nodes.WhereAsArray(node =>
            node is TMethodDeclaration method &&
            IsTestMethod(method, semanticModel, testAttributeTypes, inheritableTestAttributeTypes, cancellationToken));
    }

    private async Task<ImmutableArray<SyntaxNode>> GetPotentialTestNodesAsync(
        Document document, TextSpan textSpan, bool useSemanticDiscovery, CancellationToken cancellationToken)
    {
        var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
        var methodsInRange = root.DescendantNodesAndSelf(descendIntoChildren: ShouldDescend, descendIntoTrivia: false).OfType<TMethodDeclaration>();
        var semanticModel = useSemanticDiscovery
            ? await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false)
            : null;
        var (testAttributeTypes, inheritableTestAttributeTypes) = semanticModel is null
            ? ([], [])
            : GetTestAttributeTypes(semanticModel.Compilation);

        using var _ = ArrayBuilder<SyntaxNode>.GetInstance(out var testMethods);
        foreach (var method in methodsInRange)
        {
            if (IsTestMethod(method, semanticModel, testAttributeTypes, inheritableTestAttributeTypes, cancellationToken))
            {
                testMethods.Add(method);
            }
        }

        return testMethods.ToImmutableArray();

        bool ShouldDescend(SyntaxNode node)
        {
            if (node is ICompilationUnitSyntax)
            {
                return true;
            }

            // If the text span doesn't intersect with the node at all we don't need to explore it.
            return node.Span.IntersectsWith(textSpan) && DescendIntoChildren(node);
        }
    }

    private bool IsTestMethod(
        TMethodDeclaration method,
        SemanticModel? semanticModel,
        ImmutableArray<INamedTypeSymbol> testAttributeTypes,
        ImmutableArray<INamedTypeSymbol> inheritableTestAttributeTypes,
        CancellationToken cancellationToken)
    {
        if (semanticModel is null)
        {
            return IsTestMethod(method);
        }

        var methodSymbol = semanticModel.GetDeclaredSymbol(method, cancellationToken);
        if (methodSymbol is null)
        {
            return false;
        }

        foreach (var attribute in methodSymbol.GetAttributes())
        {
            if (attribute.AttributeClass is not { } attributeClass)
            {
                continue;
            }

            if (testAttributeTypes.Contains(attributeClass, SymbolEqualityComparer.Default))
            {
                return true;
            }

            for (var baseType = attributeClass.BaseType; baseType is not null; baseType = baseType.BaseType)
            {
                if (inheritableTestAttributeTypes.Contains(baseType, SymbolEqualityComparer.Default))
                {
                    return true;
                }
            }
        }

        return false;
    }

    private (ImmutableArray<INamedTypeSymbol> testAttributeTypes, ImmutableArray<INamedTypeSymbol> inheritableTestAttributeTypes) GetTestAttributeTypes(
        Compilation compilation)
    {
        using var _1 = ArrayBuilder<INamedTypeSymbol>.GetInstance(out var testAttributeTypes);
        using var _2 = ArrayBuilder<INamedTypeSymbol>.GetInstance(out var inheritableTestAttributeTypes);

        foreach (var metadata in TestFrameworkMetadata)
        {
            foreach (var metadataName in metadata.TestAttributeMetadataNames)
            {
                var attributeType = compilation.GetTypeByMetadataName(metadataName);
                if (attributeType is not null)
                {
                    testAttributeTypes.Add(attributeType);
                    if (metadata.SupportsDerivedTestAttributes)
                    {
                        inheritableTestAttributeTypes.Add(attributeType);
                    }
                }
            }
        }

        return (testAttributeTypes.ToImmutableAndClear(), inheritableTestAttributeTypes.ToImmutableAndClear());
    }
}