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

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);
            },
        };

    private static Option<IImmutableList<Glob>> IncludeOption { get; } =
        new("--include", "-i")
        {
            Description =
                "Glob patterns matched against project filenames (e.g. 'MyApp*.csproj'). Include projects matching any pattern.",
            Arity = ArgumentArity.OneOrMore,
            CustomParser = PatternParser,
        };

    private static Option<IImmutableList<Glob>> ExcludeOption { get; } =
        new("--exclude", "-e")
        {
            Description =
                "Glob patterns matched against project filenames (e.g. '*Tests*'). Exclude projects matching any pattern.",
            Arity = ArgumentArity.OneOrMore,
            CustomParser = PatternParser,
        };

    private static IImmutableList<Glob>? PatternParser(ArgumentResult result)
    {
        if (result.Tokens is [])
        {
            result.AddError("No pattern specified.");
            return default;
        }
        return [.. result.Tokens.Select(token => new Glob(token.Value))];
    }

    private static Option<FileInfo> ConfigOption { get; } =
        new("--config")
        {
            Description = "JSON routes overriding ~/.cfa.json and ./.cfa.json (both loaded optionally).",
        };

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

    internal sealed class ScanAction : AsynchronousCommandLineAction
    {
        public override async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
        {
            var solutionFile = parseResult.GetRequiredValue(PathArgument);
            var options = new ScannerOptions()
            {
                Messages = MessageOptions.LoadJson(parseResult.GetValue(ConfigOption)?.FullName),
                IncludePatterns = parseResult.GetValue(IncludeOption) ?? [],
                ExcludePatterns = parseResult.GetValue(ExcludeOption) ?? [],
            };

            var callGraphAnalysis = await AnsiConsole
                .Status()
                .StartAsync(
                    "Loading solution...",
                    async ctx =>
                    {
                        using var scanner = await Scanner.Create(solutionFile, options, cancellationToken);

                        ctx.Status = "Building call graph...";
                        var (startFile, startLine) = parseResult.GetRequiredValue(StartFileOption);
                        var callGraph = await scanner.BuildCallGraph(startFile, startLine, cancellationToken);

                        ctx.Status = "Analyzing call graph...";
                        return Analyzer.Analyze(callGraph);
                    }
                );

            PrintCallGraph(
                callGraphAnalysis.Origin,
                callGraphAnalysis.CalledFrom,
                callGraphAnalysis.Appearances,
                reverse: true
            );
            PrintMember(callGraphAnalysis.Origin, callGraphAnalysis.Appearances[callGraphAnalysis.Origin]);
            PrintCallGraph(callGraphAnalysis.Origin, callGraphAnalysis.CallsTo, callGraphAnalysis.Appearances);

            return 0;
        }

        private static void PrintCallGraph(
            ISymbol origin,
            IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> links,
            IImmutableDictionary<ISymbol, uint> appearances,
            bool reverse = false,
            string indent = ""
        )
        {
            if (!links.TryGetValue(origin, out var direct))
            {
                return;
            }

            for (var i = 0; i < direct.Count; i++)
            {
                var target = direct[i].Target;
                var isEnd = reverse ? i == 0 : i == direct.Count - 1;
                var branch = indent + (isEnd ? (reverse ? "┌─ " : "└─ ") : "├─ ");
                var reach = $"({direct[i].Reach}) ";

                if (!reverse)
                {
                    PrintMember(target, appearances[target], branch, reach);
                }

                PrintCallGraph(target, links.Remove(origin), appearances, reverse, indent + (isEnd ? "   " : "│  "));

                if (reverse)
                {
                    PrintMember(target, appearances[target], branch, reach);
                }
            }
        }

        private static void PrintMember(ISymbol origin, uint appearances, string branch = "", string reach = "")
        {
            Console.Write(branch);
            Console.Write(reach);
            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.Write($" ({appearances})");
            Console.WriteLine();
        }
    }
}