📄 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;

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

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

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

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

            PrintCallGraph(callGraph.Origin, callGraph.CalledFrom, reverse: true);
            PrintMember(callGraph.Origin);
            PrintCallGraph(callGraph.Origin, callGraph.CallsTo);

            return 0;
        }

        private static void PrintCallGraph(
            ISymbol origin,
            IImmutableDictionary<ISymbol, IImmutableList<Call>> links,
            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 ? "┌─ " : "└─ ") : "├─ ");

                if (!reverse)
                {
                    PrintMember(target, branch);
                }

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

                if (reverse)
                {
                    PrintMember(target, branch);
                }
            }
        }

        private static void PrintMember(ISymbol origin, 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();
        }
    }
}