📄 core/src/Scanner.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;

namespace Cfa;

public sealed class Scanner : IDisposable
{
    public static async Task<Scanner> Create(FileInfo solutionFile, CancellationToken cancellationToken = default)
    {
        var workspace = MSBuildWorkspace.Create();
        var solution = await workspace.OpenSolutionAsync(solutionFile.FullName, cancellationToken: cancellationToken);
        var compilations = await solution
            .Projects.ToAsyncEnumerable()
            .Select(async (p, ct) => await p.GetCompilationAsync(ct))
            .OfType<Compilation>()
            .ToListAsync(cancellationToken);
        return new Scanner(workspace, solution, compilations);
    }

    private readonly Workspace workspace;
    private readonly Solution solution;
    private readonly IReadOnlyCollection<Compilation> compilations;

    internal Scanner(Workspace workspace, Solution solution, IReadOnlyCollection<Compilation> compilations)
    {
        this.workspace = workspace;
        this.solution = solution;
        this.compilations = compilations;
    }

    public async Task<CallGraph> BuildCallGraph(
        FileInfo startFile,
        int startLine,
        CancellationToken cancellationToken = default
    )
    {
        var startMethod = await FindMethodAt(startFile, startLine, cancellationToken);
        var semanticModel = compilations.GetSemanticModel(startMethod.SyntaxTree);
        var symbol =
            semanticModel.GetDeclaredSymbol(startMethod, cancellationToken)
            ?? throw new InvalidOperationException($"Unable to get declared symbol for method: {startMethod}");

        return new(symbol, CalleeBuilder.FindCallees(compilations, symbol, cancellationToken));
    }

    private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
    {
        var syntaxTree =
            compilations.SelectMany(c => c.SyntaxTrees).FirstOrDefault(st => st.FilePath == startFile.FullName)
            ?? throw new InvalidOperationException($"Unable to get syntax tree for start file: {startFile.FullName}");
        var root =
            await syntaxTree.GetRootAsync(cancellationToken)
            ?? throw new InvalidOperationException($"Unable to get root for start file: {startFile.FullName}");
        return root.DescendantNodesAndSelf()
                .LastOrDefault(m =>
                    m
                        is MethodDeclarationSyntax
                            or ConstructorDeclarationSyntax
                            or PropertyDeclarationSyntax
                            or IndexerDeclarationSyntax
                            or LambdaExpressionSyntax
                    && m.GetLocation().GetLineSpan() is var lineSpan
                    && lineSpan.StartLinePosition.Line + 1 <= startLine
                    && startLine <= lineSpan.EndLinePosition.Line + 1
                )
            ?? throw new InvalidOperationException(
                $"Unable to find method at line {startLine} in start file: {startFile.FullName}"
            );
    }

    public void Dispose()
    {
        workspace.Dispose();
    }
}