📄 Core/ReachabilityAnalyzer.cs
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Reacher.MemberCollectors;

namespace Reacher;

internal sealed class ReachabilityAnalyzer(IReadOnlyCollection<Compilation> compilations)
{
    private readonly Dictionary<ISymbol, ReachabilityConfidence> reachableMembers = new(SymbolEqualityComparer.Default);

    private readonly HashSet<ISymbol> implementableMembers = new(SymbolEqualityComparer.Default);

    public void Analyze(ISymbol member, CancellationToken cancellationToken) =>
        Analyze(member, ReachabilityConfidence.DefinitelyReachable, cancellationToken);

    private void Analyze(
        ISymbol member,
        ReachabilityConfidence reachabilityConfidence,
        CancellationToken cancellationToken
    )
    {
        member = GetCanonicalMemberSymbol(member);

        if (
            !IsUserDefined(member)
            || !TryGetAssociatedCompilation(member, out var compilation)
            || reachableMembers.TryGetValue(member, out var existingConfidence)
            || reachabilityConfidence < existingConfidence
        )
        {
            return;
        }
        reachableMembers.Add(member, reachabilityConfidence);

        foreach (var declaration in member.DeclaringSyntaxReferences)
        {
            AnalyzeDeclaration(compilation, declaration, reachabilityConfidence, cancellationToken);
        }

        if (member.IsAbstract || member.IsVirtual)
        {
            implementableMembers.Add(member);
        }
    }

    private static ISymbol GetCanonicalMemberSymbol(ISymbol member) =>
        member switch
        {
            IMethodSymbol { ReducedFrom: ISymbol unreduced } => unreduced,
            _ => member,
        };

    private bool IsUserDefined(ISymbol member) =>
        !member.IsImplicitlyDeclared
        && (member.CanBeReferencedByName || member.Name is "<Main>$")
        && compilations.Any(c => SymbolEqualityComparer.Default.Equals(c.SourceModule, member.ContainingModule));

    private bool TryGetAssociatedCompilation(ISymbol member, [NotNullWhen(true)] out Compilation? compilation)
    {
        compilation = compilations.FirstOrDefault(c =>
            member.DeclaringSyntaxReferences.Any(d => !d.Span.IsEmpty && c.ContainsSyntaxTree(d.SyntaxTree))
        );
        return compilation is not null;
    }

    private void AnalyzeDeclaration(
        Compilation compilation,
        SyntaxReference declaration,
        ReachabilityConfidence reachabilityConfidence,
        CancellationToken cancellationToken
    )
    {
        var originalNode = declaration.GetSyntax(cancellationToken);
        var expressions = originalNode
            .DescendantNodes(ShouldDescendInto(originalNode))
            .OfType<ExpressionSyntax>()
            .Where(e =>
                e is IdentifierNameSyntax or ImplicitObjectCreationExpressionSyntax or ObjectCreationExpressionSyntax
            );

        var semanticModel = compilation.GetSemanticModel(declaration.SyntaxTree);

        foreach (var identifier in expressions)
        {
            if (!semanticModel.IsReachable(identifier))
            {
                continue;
            }
            if (
                semanticModel.GetSymbolInfo(identifier, cancellationToken) is not { Symbol: ISymbol member }
                || member is not (IMethodSymbol or IPropertySymbol)
            )
            {
                continue;
            }
            Analyze(member, reachabilityConfidence, cancellationToken);
        }
    }

    private static Func<SyntaxNode, bool> ShouldDescendInto(SyntaxNode originalNode) =>
        syntaxNode => syntaxNode == originalNode || !syntaxNode.IsKind(SyntaxKind.LocalFunctionStatement);

    public ReachabilityAnalysis GetAnalysis(CancellationToken cancellationToken)
    {
        var unreachableMembers = AnalyzeUnreachableMembersMembers(cancellationToken);
        unreachableMembers = AnalyzeUnreachableMembersMembers(cancellationToken);
        return new([.. reachableMembers.Select(kvp => new ReachableMember(kvp.Key, kvp.Value))], unreachableMembers);
    }

    private HashSet<ISymbol> AnalyzeUnreachableMembersMembers(CancellationToken cancellationToken)
    {
        var unreachableMembers = GetUnreachableMembers();
        AnalyzeProbablyReachableMembers(unreachableMembers, cancellationToken);
        unreachableMembers.ExceptWith(reachableMembers.Keys);
        return unreachableMembers;
    }

    private HashSet<ISymbol> GetUnreachableMembers()
    {
        var collector = new PredicateMembersCollector(m => IsUserDefined(m) && !reachableMembers.ContainsKey(m));
        compilations.Accept(collector);
        return new(collector.Members, SymbolEqualityComparer.Default);
    }

    private void AnalyzeProbablyReachableMembers(
        IReadOnlySet<ISymbol> unreachableMembers,
        CancellationToken cancellationToken
    )
    {
        foreach (var member in unreachableMembers)
        {
            if (IsProbablyReachable(member))
            {
                Analyze(member, ReachabilityConfidence.ProbablyReachable, cancellationToken);
            }
        }
    }

    private bool IsProbablyReachable(ISymbol member)
    {
        var possibleTransientTargets = member.ExplicitOrImplicitInterfaceImplementations();
        if (member.GetOverriddenMember() is { } overriddenMember)
        {
            possibleTransientTargets.Add(overriddenMember);
        }
        return possibleTransientTargets.Any(m => reachableMembers.ContainsKey(m) || !IsUserDefined(m));
    }
}