📄 core/src/CallGraphBuilder.cs
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.FindSymbols;

namespace Cfa;

internal sealed class CallGraphBuilder
{
    public static async Task<CallGraph> BuildCallGraph(
        ScannerOptions options,
        Solution solution,
        IReadOnlyCollection<Compilation> compilations,
        ISymbol origin,
        CancellationToken cancellationToken
    )
    {
        var builder = new CallGraphBuilder(options, solution, compilations);

        await builder.FindCallees(origin, cancellationToken);
        builder.InvertCallees(origin);
        await builder.FindCallers(origin, cancellationToken);

        return new CallGraph(
            origin,
            builder.ReachableFromSymbols.ToImmutable(),
            builder.ReachesToSymbols.ToImmutable()
        );
    }

    private readonly Solution solution;
    private readonly IReadOnlyCollection<Compilation> compilations;
    private readonly HashSet<ProjectId> includedProjects;
    private readonly HashSet<SyntaxTree> includedTrees;
    private readonly IReadOnlyCollection<INamedTypeSymbol> types;
    private readonly MessageLinks messageLinks;

    private readonly HashSet<ISymbol> seenFrom = [with(SymbolEqualityComparer.Default)];
    internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachableFromSymbols { get; } =
        ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
    private readonly HashSet<ISymbol> seenTo = [with(SymbolEqualityComparer.Default)];
    internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachesToSymbols { get; } =
        ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);

    internal CallGraphBuilder(ScannerOptions options, Solution solution, IReadOnlyCollection<Compilation> compilations)
    {
        this.solution = solution;
        this.compilations = compilations;
        includedProjects =
        [
            .. solution
                .Projects.Where(project => options.IncludesProject(project.FilePath ?? project.Name))
                .Select(project => project.Id),
        ];
        includedTrees =
        [
            .. compilations
                .SelectMany(compilation => compilation.SyntaxTrees)
                .Where(tree =>
                    solution.GetDocument(tree) is { Project.Id: var projectId } && includedProjects.Contains(projectId)
                ),
        ];
        types =
        [
            .. compilations
                .SelectMany(compilation => compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type))
                .OfType<INamedTypeSymbol>(),
        ];
        messageLinks = new MessageLinks(options.Messages, compilations, includedTrees, solution);
    }

    internal async Task FindCallees(ISymbol symbol, CancellationToken cancellationToken)
    {
        if (!seenFrom.Add(symbol))
        {
            return;
        }

        var seenCall = new Dictionary<ISymbol, LinkKind>(SymbolEqualityComparer.Default);
        foreach (var handler in await messageLinks.Targets(symbol, cancellationToken))
        {
            AddCall(handler, LinkKind.Event);
        }
        foreach (var reference in symbol.DeclaringSyntaxReferences)
        {
            var syntax = reference.GetSyntax(cancellationToken);
            var semanticModelForSyntax = compilations.GetSemanticModel(syntax.SyntaxTree);
            var accesses = syntax
                .DescendantNodesAndSelf(node =>
                    node == syntax
                    || node is not AnonymousFunctionExpressionSyntax
                    || semanticModelForSyntax.GetSymbolInfo(node, cancellationToken).Symbol is not { } handler
                    || !messageLinks.IsHandler(handler)
                )
                .Where(node =>
                    node
                        is SimpleNameSyntax
                            or ElementAccessExpressionSyntax
                            or ElementBindingExpressionSyntax
                            or BaseObjectCreationExpressionSyntax
                            or ConstructorInitializerSyntax
                );
            foreach (var access in accesses)
            {
                if (messageLinks.IsRegistrationArgument(access))
                {
                    continue;
                }
                var accessedSymbol = semanticModelForSyntax.GetSymbolInfo(access, cancellationToken).Symbol;
                if (accessedSymbol is not (IMethodSymbol or IPropertySymbol))
                {
                    continue;
                }

                if (accessedSymbol.IsAbstract || accessedSymbol.IsVirtual)
                {
                    foreach (var implementation in FindImplementations(accessedSymbol))
                    {
                        AddCall(implementation, LinkKind.Abstract);
                    }
                }

                if (!accessedSymbol.IsAbstract)
                {
                    AddCall(accessedSymbol, LinkKind.Direct);
                }
            }
        }
        ReachableFromSymbols.Add(symbol, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
        foreach (var target in seenCall.Keys)
        {
            await FindCallees(target, cancellationToken);
        }

        void AddCall(ISymbol target, LinkKind linkKind)
        {
            if (
                !IsDeclaredInSource(target)
                || linkKind is LinkKind.Abstract && target.IsAbstract
                || target is IPropertySymbol property && !HasBody(property, cancellationToken)
            )
            {
                return;
            }

            if (!seenCall.TryAdd(target, linkKind) && linkKind is LinkKind.Direct)
            {
                seenCall[target] = LinkKind.Direct;
            }
        }
    }

    private IEnumerable<ISymbol> FindImplementations(ISymbol member)
    {
        foreach (var type in types)
        {
            if (member.ContainingType.TypeKind is TypeKind.Interface)
            {
                if (type.FindImplementationForInterfaceMember(member) is { } implementation)
                {
                    yield return implementation;
                }
                continue;
            }

            foreach (var candidate in type.GetMembers(member.Name))
            {
                for (
                    var overridden = GetOverriddenMember(candidate);
                    overridden is not null;
                    overridden = GetOverriddenMember(overridden)
                )
                {
                    if (SymbolEqualityComparer.Default.Equals(overridden.OriginalDefinition, member.OriginalDefinition))
                    {
                        yield return candidate;
                        break;
                    }
                }
            }
        }
    }

    internal async Task FindCallers(ISymbol origin, CancellationToken cancellationToken)
    {
        if (!seenTo.Add(origin))
        {
            return;
        }

        var contracts = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
        for (var member = GetOverriddenMember(origin); member is not null; member = GetOverriddenMember(member))
        {
            contracts.Add(member.OriginalDefinition);
        }
        foreach (var interfaceType in origin.ContainingType.AllInterfaces)
        {
            foreach (var member in interfaceType.GetMembers())
            {
                var implementation = origin.ContainingType.FindImplementationForInterfaceMember(member);
                if (
                    SymbolEqualityComparer.Default.Equals(implementation?.OriginalDefinition, origin.OriginalDefinition)
                )
                {
                    contracts.Add(member.OriginalDefinition);
                }
            }
        }

        var seenCall = new Dictionary<ISymbol, LinkKind>(SymbolEqualityComparer.Default);
        var references = origin is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }
            ? []
            : await SymbolFinder.FindReferencesAsync(origin, solution, cancellationToken);
        foreach (var publisher in await messageLinks.Callers(origin, references, cancellationToken))
        {
            if (seenCall.TryAdd(publisher, LinkKind.Event))
            {
                await FindCallers(publisher, cancellationToken);
            }
        }
        foreach (var referencedSymbol in references)
        {
            var definition = referencedSymbol.Definition.OriginalDefinition;
            var linkKind = SymbolEqualityComparer.Default.Equals(definition, origin.OriginalDefinition)
                ? LinkKind.Direct
                : LinkKind.Abstract;
            if (linkKind is not LinkKind.Direct && !contracts.Contains(definition))
            {
                continue;
            }

            foreach (var reference in referencedSymbol.Locations)
            {
                var root = await reference.Document.GetSyntaxRootAsync(cancellationToken);
                if (
                    root is not null
                    && messageLinks.IsRegistrationArgument(root.FindNode(reference.Location.SourceSpan))
                )
                {
                    continue;
                }
                var semanticModel = await reference.Document.GetSemanticModelAsync(cancellationToken);
                var caller = semanticModel?.GetEnclosingSymbol(reference.Location.SourceSpan.Start, cancellationToken);
                while (
                    caller is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }
                    && !messageLinks.IsHandler(caller)
                )
                {
                    caller = caller.ContainingSymbol;
                }
                if (caller is IMethodSymbol { AssociatedSymbol: IPropertySymbol property })
                {
                    caller = property;
                }
                if (caller is not (IMethodSymbol or IPropertySymbol) || !IsDeclaredInSource(caller))
                {
                    continue;
                }

                if (seenCall.TryAdd(caller, linkKind))
                {
                    await FindCallers(caller, cancellationToken);
                }
                else if (linkKind is LinkKind.Direct)
                {
                    seenCall[caller] = LinkKind.Direct;
                }
            }
        }
        ReachesToSymbols.Add(origin, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
    }

    private static ISymbol? GetOverriddenMember(ISymbol member) =>
        member switch
        {
            IMethodSymbol method => method.OverriddenMethod,
            IPropertySymbol property => property.OverriddenProperty,
            _ => null,
        };

    private bool IsDeclaredInSource(ISymbol symbol) =>
        (!symbol.IsImplicitlyDeclared || symbol is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction })
        && symbol.DeclaringSyntaxReferences.Any(reference => includedTrees.Contains(reference.SyntaxTree));

    private static bool HasBody(IPropertySymbol property, CancellationToken cancellationToken) =>
        property.DeclaringSyntaxReferences.Any(reference =>
            reference.GetSyntax(cancellationToken) switch
            {
                PropertyDeclarationSyntax { ExpressionBody: not null } => true,
                IndexerDeclarationSyntax { ExpressionBody: not null } => true,
                BasePropertyDeclarationSyntax declaration => declaration.AccessorList?.Accessors.Any(accessor =>
                    accessor.Body is not null || accessor.ExpressionBody is not null
                )
                    is true,
                _ => false,
            }
        );

    private void InvertCallees(ISymbol origin)
    {
        ReachesToSymbols.AddRange(
            ReachableFromSymbols.Invert().Where(kvp => !SymbolEqualityComparer.Default.Equals(kvp.Key, origin))
        );
        seenTo.UnionWith(ReachesToSymbols.Keys);
    }
}