📄 cli/src/ScanCommand.cs
using System;
using System.Collections.Immutable;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;

namespace Cfa.Cli;

internal sealed class ScanCommand : Command
{
    private static Argument<FileInfo> PathArgument { get; } =
        new("path")
        {
            Description =
                "Path to the solution file, or the directory containing it. (Defaults to the current directory.)",
            DefaultValueFactory = result =>
            {
                var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
                switch (
                    directory
                        .GetFiles("*.sln?", SearchOption.TopDirectoryOnly)
                        .Where(f => f.Extension is ".sln" or ".slnx")
                        .ToArray()
                )
                {
                    case []:
                        result.AddError("No solution files found in the current directory.");
                        return null!;
                    case [var file]:
                        return file;
                    default:
                        result.AddError("Multiple solution files found in the current directory.");
                        return null!;
                }
            },
            CustomParser = result =>
            {
                if (result.Tokens is not [var token] || string.IsNullOrWhiteSpace(token.Value))
                {
                    result.AddError("No path specified.");
                    return null;
                }
                if (Directory.Exists(token.Value))
                {
                    var directory = new DirectoryInfo(token.Value);
                    switch (
                        directory
                            .GetFiles("*.sln?", SearchOption.TopDirectoryOnly)
                            .Where(f => f.Extension is ".sln" or ".slnx")
                            .ToArray()
                    )
                    {
                        case []:
                            result.AddError("No solution files found in the specified directory.");
                            return null;
                        case [var file]:
                            return file;
                        default:
                            result.AddError("Multiple solution files found in the specified directory.");
                            return null;
                    }
                }
                if (File.Exists(token.Value))
                {
                    return new FileInfo(token.Value);
                }
                result.AddError("The specified path does not exist.");
                return null;
            },
        };

    private static Option<(FileInfo, int)> StartFileOption { get; } =
        new("--start-file", "-f")
        {
            Description = "The start file and line number for the analysis.",
            Required = true,
            CustomParser = result =>
            {
                if (result.Tokens is not [var fileAndLine])
                {
                    result.AddError("Start file must be specified.");
                    return default;
                }
                if (fileAndLine.Value.Split(":", 2) is not [var file, var lineString])
                {
                    result.AddError("Start file must include a line number separated by ':'.");
                    return default;
                }
                if (!int.TryParse(lineString, out var line) || line <= 0)
                {
                    result.AddError("Invalid line number specified.");
                    return default;
                }
                return (new FileInfo(file), line);
            },
        };

    public ScanCommand()
        : base("scan", "Scan the code for analysis")
    {
        Action = new ScanAction();
        Arguments.Add(PathArgument);
        Options.Add(StartFileOption);
    }

    internal sealed class ScanAction : AsynchronousCommandLineAction
    {
        public override async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
        {
            var solutionFile = parseResult.GetRequiredValue(PathArgument);
            using var scanner = await Scanner.Create(solutionFile, cancellationToken);

            var (startFile, startLine) = parseResult.GetRequiredValue(StartFileOption);
            var callGraph = await scanner.BuildCallGraph(startFile, startLine, cancellationToken);

            PrintCallGraph(callGraph.Origin, callGraph.Callees);

            return 0;
        }

        private static void PrintCallGraph(
            ISymbol origin,
            IImmutableDictionary<ISymbol, IImmutableList<Call>> callees,
            string indent = "",
            string branch = ""
        )
        {
            Console.Write(branch);
            var syntaxTree = origin.DeclaringSyntaxReferences.FirstOrDefault()?.SyntaxTree;
            if (syntaxTree is not null)
            {
                var path = syntaxTree.FilePath;
                var start = syntaxTree.GetLineSpan(origin.DeclaringSyntaxReferences.First().Span).StartLinePosition;
                var line = start.Line + 1;
                var column = start.Character + 1;
                Console.Write($"\e]8;;file://{path}#{line}:{column}\e\\");
            }
            Console.Write($"{origin}");
            if (syntaxTree is not null)
            {
                Console.Write("\e]8;;\e\\");
            }
            Console.WriteLine();

            if (!callees.TryGetValue(origin, out var directCallees))
            {
                return;
            }

            for (var i = 0; i < directCallees.Count; i++)
            {
                PrintCallGraph(
                    directCallees[i].Target,
                    callees.Remove(origin),
                    indent + (i < directCallees.Count - 1 ? "│  " : "   "),
                    indent + (i < directCallees.Count - 1 ? "├─ " : "└─ ")
                );
            }
        }
    }
}