Commit: f9ae0ce
Parent: 2a73392

Restructure call graph hierarchy

Mårten Åsberg committed on 2026-10-27 at 20:22
cli/src/ScanCommand.cs +11 -6
diff --git a/cli/src/ScanCommand.cs b/cli/src/ScanCommand.cs
index 4532a3e..e549ecf 100644
@@ -124,7 +124,7 @@ internal sealed class ScanCommand : Command
private static void PrintCallGraph(
ISymbol origin,
IImmutableList<Callee> callees,
IImmutableDictionary<ISymbol, IImmutableList<Call>> callees,
string indent = "",
string branch = ""
)
@@ -146,13 +146,18 @@ internal sealed class ScanCommand : Command
}
Console.WriteLine();
for (var i = 0; i < callees.Count; i++)
if (!callees.TryGetValue(origin, out var directCallees))
{
return;
}
for (var i = 0; i < directCallees.Count; i++)
{
PrintCallGraph(
callees[i].Target,
callees[i].Callees,
indent + (i < callees.Count - 1 ? "│ " : " "),
indent + (i < callees.Count - 1 ? "├─ " : "└─ ")
directCallees[i].Target,
callees.Remove(origin),
indent + (i < directCallees.Count - 1 ? "│ " : " "),
indent + (i < directCallees.Count - 1 ? "├─ " : "└─ ")
);
}
}
core/src/CallGraph.cs +2 -2
diff --git a/core/src/CallGraph.cs b/core/src/CallGraph.cs
index fc92386..e5ec0f9 100644
@@ -3,9 +3,9 @@ using Microsoft.CodeAnalysis;
namespace Cfa;
public sealed record CallGraph(ISymbol Origin, IImmutableList<Callee> Callees);
public sealed record CallGraph(ISymbol Origin, IImmutableDictionary<ISymbol, IImmutableList<Call>> Callees);
public sealed record Callee(ISymbol Target, LinkKind LinkKind, IImmutableList<Callee> Callees);
public sealed record Call(ISymbol Target, LinkKind LinkKind);
public enum LinkKind
{
core/src/CalleBuilder.cs +30 -13
diff --git a/core/src/CalleBuilder.cs b/core/src/CalleBuilder.cs
index ce496e5..9c0381c 100644
@@ -7,20 +7,38 @@ using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Cfa;
internal sealed class CalleeBuilder(IReadOnlyCollection<Compilation> compilations)
internal sealed class CalleeBuilder
{
private readonly IReadOnlyCollection<Compilation> compilations = compilations;
private readonly HashSet<ISymbol> visitedSymbols = [with(SymbolEqualityComparer.Default)];
public static IImmutableDictionary<ISymbol, IImmutableList<Call>> FindCallees(
IReadOnlyCollection<Compilation> compilations,
ISymbol symbol,
CancellationToken cancellationToken
)
{
var builder = new CalleeBuilder(compilations);
builder.FindCallees(symbol, cancellationToken);
return builder.reachableFromSymbols.ToImmutable();
}
public IImmutableList<Callee> FindCallees(ISymbol symbol, CancellationToken cancellationToken)
private readonly IReadOnlyCollection<Compilation> compilations;
private readonly HashSet<ISymbol> seenMember = [with(SymbolEqualityComparer.Default)];
private readonly ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder reachableFromSymbols =
ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
private CalleeBuilder(IReadOnlyCollection<Compilation> compilations)
{
if (!visitedSymbols.Add(symbol))
this.compilations = compilations;
}
private void FindCallees(ISymbol symbol, CancellationToken cancellationToken)
{
if (!seenMember.Add(symbol))
{
return [];
return;
}
var seen = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
var callees = ImmutableList.CreateBuilder<Callee>();
var seenCall = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
var callees = ImmutableList.CreateBuilder<Call>();
foreach (var reference in symbol.DeclaringSyntaxReferences)
{
var syntax = reference.GetSyntax(cancellationToken);
@@ -35,7 +53,7 @@ internal sealed class CalleeBuilder(IReadOnlyCollection<Compilation> compilation
var accessedSymbol = semanticModelForSyntax.GetSymbolInfo(access, cancellationToken).Symbol;
if (
accessedSymbol is not (IMethodSymbol or IPropertySymbol)
|| !seen.Add(accessedSymbol)
|| !seenCall.Add(accessedSymbol)
|| !IsDeclaredInSource(accessedSymbol)
)
{
@@ -47,12 +65,11 @@ internal sealed class CalleeBuilder(IReadOnlyCollection<Compilation> compilation
continue;
}
callees.Add(
new Callee(accessedSymbol, LinkKind.Direct, FindCallees(accessedSymbol, cancellationToken))
);
callees.Add(new Call(accessedSymbol, LinkKind.Direct));
FindCallees(accessedSymbol, cancellationToken);
}
}
return callees.ToImmutable();
reachableFromSymbols.Add(symbol, callees.ToImmutable());
}
private static bool IsDeclaredInSource(ISymbol symbol) =>
core/src/Scanner.cs +1 -2
diff --git a/core/src/Scanner.cs b/core/src/Scanner.cs
index 716327f..401fcde 100644
@@ -48,8 +48,7 @@ public sealed class Scanner : IDisposable
semanticModel.GetDeclaredSymbol(startMethod, cancellationToken)
?? throw new InvalidOperationException($"Unable to get declared symbol for method: {startMethod}");
var calleeBuilder = new CalleeBuilder(compilations);
return new(symbol, calleeBuilder.FindCallees(symbol, cancellationToken));
return new(symbol, CalleeBuilder.FindCallees(compilations, symbol, cancellationToken));
}
private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
core/tests/CalleeBuilderTests/FindCalleesShould.cs +19 -19
diff --git a/core/tests/CalleeBuilderTests/FindCalleesShould.cs b/core/tests/CalleeBuilderTests/FindCalleesShould.cs
index 35a1998..db23c1b 100644
@@ -25,10 +25,10 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
callees.ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
calls[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
}
[Fact]
@@ -45,12 +45,12 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
var property = callees.ShouldHaveSingleItem();
var property = calls[start].ShouldHaveSingleItem();
property.Target.Name.ShouldBe("Value");
property.Callees.ShouldHaveSingleItem().Target.Name.ShouldBe("GetValue");
calls[property.Target].ShouldHaveSingleItem().Target.Name.ShouldBe("GetValue");
}
[Theory]
@@ -69,10 +69,10 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
callees.ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
calls[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
}
[Theory]
@@ -93,12 +93,12 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
var property = callees.ShouldHaveSingleItem();
var property = calls[start].ShouldHaveSingleItem();
property.Target.Name.ShouldBe("Value");
property.Callees.ShouldHaveSingleItem().Target.Name.ShouldBe("Save");
calls[property.Target].ShouldHaveSingleItem().Target.Name.ShouldBe("Save");
}
[Theory]
@@ -116,10 +116,10 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
callees.ShouldBeEmpty();
calls[start].ShouldBeEmpty();
}
[Theory]
@@ -143,12 +143,12 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
var indexer = callees.ShouldHaveSingleItem();
var indexer = calls[start].ShouldHaveSingleItem();
indexer.Target.ShouldBeAssignableTo<IPropertySymbol>().IsIndexer.ShouldBeTrue();
indexer.Callees.ShouldHaveSingleItem().Target.Name.ShouldBe(method);
calls[indexer.Target].ShouldHaveSingleItem().Target.Name.ShouldBe(method);
}
[Fact]
@@ -164,13 +164,13 @@ public sealed class FindCalleesShould
""";
// Act
var callees = FindCallees(source);
var (start, calls) = FindCallees(source);
// Assert
callees.ShouldBeEmpty();
calls[start].ShouldBeEmpty();
}
private static IImmutableList<Callee> FindCallees(string source)
private static (ISymbol, IImmutableDictionary<ISymbol, IImmutableList<Call>>) FindCallees(string source)
{
var compilation = CSharpCompilation.Create(
"Example",
@@ -179,6 +179,6 @@ public sealed class FindCalleesShould
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
var start = compilation.GetTypeByMetadataName("Example")!.GetMembers("Start").Single();
return new CalleeBuilder([compilation]).FindCallees(start, CancellationToken.None);
return (start, CalleeBuilder.FindCallees([compilation], start, CancellationToken.None));
}
}