📄 core/src/Scanner.cs
using System;
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);
        return new Scanner(workspace, solution);
    }

    private readonly Workspace workspace;
    private readonly Solution solution;

    private Scanner(Workspace workspace, Solution solution)
    {
        this.workspace = workspace;
        this.solution = solution;
    }

    public async Task<ISymbol> BuildCallGraph(
        FileInfo startFile,
        int startLine,
        CancellationToken cancellationToken = default
    )
    {
        var startMethod = await FindMethodAt(startFile, startLine, cancellationToken);
        var semanticModel =
            await (
                solution.GetDocument(startMethod.SyntaxTree)
                ?? throw new InvalidOperationException(
                    $"Unable to get document for syntax tree: {startMethod.SyntaxTree}"
                )
            ).GetSemanticModelAsync(cancellationToken)
            ?? throw new InvalidOperationException(
                $"Unable to get semantic model for syntax tree: {startMethod.SyntaxTree}"
            );
        var symbol =
            semanticModel.GetDeclaredSymbol(startMethod, cancellationToken)
            ?? throw new InvalidOperationException($"Unable to get declared symbol for method: {startMethod}");
        return symbol;
    }

    private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
    {
        var document =
            solution.Projects.SelectMany(p => p.Documents).FirstOrDefault(d => d.FilePath == startFile.FullName)
            ?? throw new FileNotFoundException($"Start file not found in solution: {startFile.FullName}");
        var syntaxTree =
            await document.GetSyntaxTreeAsync(cancellationToken)
            ?? 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 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();
    }
}