cli/src/ScanCommand.cs
+33
-19
diff --git a/cli/src/ScanCommand.cs b/cli/src/ScanCommand.cs
index e549ecf..ffc395e 100644
@@ -117,18 +117,47 @@ internal sealed class ScanCommand : Command
var (startFile, startLine) = parseResult.GetRequiredValue(StartFileOption);
var callGraph = await scanner.BuildCallGraph(startFile, startLine, cancellationToken);
PrintCallGraph(callGraph.Origin, callGraph.Callees);
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>> callees,
string indent = "",
string branch = ""
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)
@@ -145,21 +174,6 @@ internal sealed class ScanCommand : Command
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 ? "├─ " : "└─ ")
);
}
}
}
}
core/src/CallGraph.cs
+5
-1
diff --git a/core/src/CallGraph.cs b/core/src/CallGraph.cs
index c0d2401..8d2f514 100644
@@ -3,7 +3,11 @@ using Microsoft.CodeAnalysis;
namespace Cfa;
public sealed record CallGraph(ISymbol Origin, IImmutableDictionary<ISymbol, IImmutableList<Call>> Callees);
public sealed record CallGraph(
ISymbol Origin,
IImmutableDictionary<ISymbol, IImmutableList<Call>> CallsTo,
IImmutableDictionary<ISymbol, IImmutableList<Call>> CalledFrom
);
public sealed record Call(ISymbol Target, LinkKind LinkKind);
core/src/CallGraphBuilder.cs
+109
-12
diff --git a/core/src/CalleBuilder.cs b/core/src/CallGraphBuilder.cs
similarity index 60%
rename from core/src/CalleBuilder.cs
rename to core/src/CallGraphBuilder.cs
index 71e73f5..8d0286f 100644
@@ -2,20 +2,24 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.FindSymbols;
namespace Cfa;
internal sealed class CalleeBuilder
internal sealed class CallGraphBuilder
{
public static IImmutableDictionary<ISymbol, IImmutableList<Call>> FindCallees(
public static async Task<CallGraph> BuildCallGraph(
Solution solution,
IReadOnlyCollection<Compilation> compilations,
ISymbol symbol,
ISymbol origin,
CancellationToken cancellationToken
)
{
var builder = new CalleeBuilder(
var builder = new CallGraphBuilder(
solution,
compilations,
[
.. compilations
@@ -26,25 +30,41 @@ internal sealed class CalleeBuilder
]
);
builder.FindCallees(symbol, cancellationToken);
return builder.reachableFromSymbols.ToImmutable();
builder.FindCallees(origin, cancellationToken);
builder.InvertCallees(origin);
await builder.FindCallers(origin, cancellationToken);
return new CallGraph(
origin,
builder.ReachableFromSymbols.ToImmutable(),
builder.ReachesToSymbols.ToImmutable()
);
}
private readonly Solution solution;
private readonly IReadOnlyCollection<Compilation> compilations;
private readonly IReadOnlyCollection<INamedTypeSymbol> types;
private readonly HashSet<ISymbol> seenMember = [with(SymbolEqualityComparer.Default)];
private readonly ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder reachableFromSymbols =
private readonly HashSet<ISymbol> seenFrom = [with(SymbolEqualityComparer.Default)];
internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachableFromSymbols { get; } =
ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
private readonly HashSet<ISymbol> seenTo = [with(SymbolEqualityComparer.Default)];
internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachesToSymbols { get; } =
ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
private CalleeBuilder(IReadOnlyCollection<Compilation> compilations, IReadOnlyCollection<INamedTypeSymbol> types)
internal CallGraphBuilder(
Solution solution,
IReadOnlyCollection<Compilation> compilations,
IReadOnlyCollection<INamedTypeSymbol> types
)
{
this.solution = solution;
this.compilations = compilations;
this.types = types;
}
private void FindCallees(ISymbol symbol, CancellationToken cancellationToken)
internal void FindCallees(ISymbol symbol, CancellationToken cancellationToken)
{
if (!seenMember.Add(symbol))
if (!seenFrom.Add(symbol))
{
return;
}
@@ -86,7 +106,7 @@ internal sealed class CalleeBuilder
}
}
}
reachableFromSymbols.Add(symbol, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
ReachableFromSymbols.Add(symbol, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
void AddCall(ISymbol target, LinkKind linkKind)
{
@@ -141,6 +161,75 @@ internal sealed class CalleeBuilder
}
}
internal async Task FindCallers(ISymbol origin, CancellationToken cancellationToken)
{
if (!seenTo.Add(origin))
{
return;
}
var contracts = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
for (var member = GetOverriddenMember(origin); member is not null; member = GetOverriddenMember(member))
{
contracts.Add(member.OriginalDefinition);
}
foreach (var interfaceType in origin.ContainingType.AllInterfaces)
{
foreach (var member in interfaceType.GetMembers())
{
var implementation = origin.ContainingType.FindImplementationForInterfaceMember(member);
if (
SymbolEqualityComparer.Default.Equals(implementation?.OriginalDefinition, origin.OriginalDefinition)
)
{
contracts.Add(member.OriginalDefinition);
}
}
}
var seenCall = new Dictionary<ISymbol, LinkKind>(SymbolEqualityComparer.Default);
var references = await SymbolFinder.FindReferencesAsync(origin, solution, cancellationToken);
foreach (var referencedSymbol in references)
{
var definition = referencedSymbol.Definition.OriginalDefinition;
var linkKind = SymbolEqualityComparer.Default.Equals(definition, origin.OriginalDefinition)
? LinkKind.Direct
: LinkKind.Abstract;
if (linkKind is not LinkKind.Direct && !contracts.Contains(definition))
{
continue;
}
foreach (var reference in referencedSymbol.Locations)
{
var semanticModel = await reference.Document.GetSemanticModelAsync(cancellationToken);
var caller = semanticModel?.GetEnclosingSymbol(reference.Location.SourceSpan.Start, cancellationToken);
while (caller is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction })
{
caller = caller.ContainingSymbol;
}
if (caller is IMethodSymbol { AssociatedSymbol: IPropertySymbol property })
{
caller = property;
}
if (caller is not (IMethodSymbol or IPropertySymbol) || !IsDeclaredInSource(caller))
{
continue;
}
if (seenCall.TryAdd(caller, linkKind))
{
await FindCallers(caller, cancellationToken);
}
else if (linkKind is LinkKind.Direct)
{
seenCall[caller] = LinkKind.Direct;
}
}
}
ReachesToSymbols.Add(origin, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
}
private static ISymbol? GetOverriddenMember(ISymbol member) =>
member switch
{
@@ -165,4 +254,12 @@ internal sealed class CalleeBuilder
_ => false,
}
);
private void InvertCallees(ISymbol origin)
{
ReachesToSymbols.AddRange(
ReachableFromSymbols.Invert().Where(kvp => !SymbolEqualityComparer.Default.Equals(kvp.Key, origin))
);
seenTo.UnionWith(ReachesToSymbols.Keys);
}
}
core/src/ImmutableDictionaryExtensions.cs
+19
-0
diff --git a/core/src/ImmutableDictionaryExtensions.cs b/core/src/ImmutableDictionaryExtensions.cs
new file mode 100644
index 0000000..c962d42
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Cfa;
internal static class ImmutableDictionaryExtensions
{
public static IEnumerable<KeyValuePair<ISymbol, IImmutableList<Call>>> Invert(
this ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder builder
) =>
builder
.SelectMany(kvp => kvp.Value.Select(call => (from: kvp.Key, to: call.Target, kind: call.LinkKind)))
.GroupBy(x => x.to, SymbolEqualityComparer.Default)
.Select(g =>
KeyValuePair.Create(g.Key, (IImmutableList<Call>)[.. g.Select(x => new Call(x.from, x.kind))])
);
}
core/src/Scanner.cs
+1
-1
diff --git a/core/src/Scanner.cs b/core/src/Scanner.cs
index 401fcde..4ddd1d7 100644
@@ -48,7 +48,7 @@ public sealed class Scanner : IDisposable
semanticModel.GetDeclaredSymbol(startMethod, cancellationToken)
?? throw new InvalidOperationException($"Unable to get declared symbol for method: {startMethod}");
return new(symbol, CalleeBuilder.FindCallees(compilations, symbol, cancellationToken));
return await CallGraphBuilder.BuildCallGraph(solution, compilations, symbol, cancellationToken);
}
private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
core/tests/CallGraphBuilderTests/FindCalleesShould.cs
+42
-58
diff --git a/core/tests/CalleeBuilderTests/FindCalleesShould.cs b/core/tests/CallGraphBuilderTests/FindCalleesShould.cs
similarity index 77%
rename from core/tests/CalleeBuilderTests/FindCalleesShould.cs
rename to core/tests/CallGraphBuilderTests/FindCalleesShould.cs
index 4baf023..994c96a 100644
@@ -1,19 +1,16 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Shouldly;
using Xunit;
namespace Cfa.Tests.CalleeBuilderTests;
namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class FindCalleesShould
{
[Theory]
[InlineData("Value")]
[InlineData("this.Value")]
public void FindExpressionBodiedProperties(string access)
public async Task FindExpressionBodiedProperties(string access)
{
// Arrange
var source = $$"""
@@ -25,14 +22,14 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
}
[Fact]
public void FollowMethodCallsInsideProperties()
public async Task FollowMethodCallsInsideProperties()
{
// Arrange
const string source = """
@@ -45,7 +42,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var property = calls[start].ShouldHaveSingleItem();
@@ -57,7 +54,7 @@ public sealed class FindCalleesShould
[InlineData("int Value { get { return 1; } }", "_ = Value;")]
[InlineData("int Value { get => 1; }", "_ = Value;")]
[InlineData("int Value { set { } }", "Value = 1;")]
public void FindPropertiesWithAccessorBodies(string declaration, string access)
public async Task FindPropertiesWithAccessorBodies(string declaration, string access)
{
// Arrange
var source = $$"""
@@ -69,7 +66,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
@@ -80,7 +77,7 @@ public sealed class FindCalleesShould
[InlineData("set { Save(value); }", "this.Value = 1;")]
[InlineData("set => Save(value);", "Value = 1;")]
[InlineData("set => Save(value);", "this.Value = 1;")]
public void FollowAssignedPropertiesAndMethodCallsInsideTheirSetters(string setter, string assignment)
public async Task FollowAssignedPropertiesAndMethodCallsInsideTheirSetters(string setter, string assignment)
{
// Arrange
var source = $$"""
@@ -93,7 +90,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var property = calls[start].ShouldHaveSingleItem();
@@ -104,7 +101,7 @@ public sealed class FindCalleesShould
[Theory]
[InlineData("_ = this.Value;")]
[InlineData("this.Value = 2;")]
public void IgnoreAutoPropertiesEvenWhenTheyHaveInitializers(string access)
public async Task IgnoreAutoPropertiesEvenWhenTheyHaveInitializers(string access)
{
// Arrange
var source = $$"""
@@ -116,7 +113,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldBeEmpty();
@@ -129,7 +126,7 @@ public sealed class FindCalleesShould
[InlineData("int this[int index] { get => Read(); }", "_ = this[0];", "Read")]
[InlineData("int this[int index] { set { Save(value); } }", "this[0] = 1;", "Save")]
[InlineData("int this[int index] { set => Save(value); }", "this[0] = 1;", "Save")]
public void FollowIndexersWithBodies(string declaration, string access, string method)
public async Task FollowIndexersWithBodies(string declaration, string access, string method)
{
// Arrange
var source = $$"""
@@ -143,7 +140,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var indexer = calls[start].ShouldHaveSingleItem();
@@ -152,7 +149,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void IgnoreIndexersWithoutBodies()
public async Task IgnoreIndexersWithoutBodies()
{
// Arrange
const string source = """
@@ -164,7 +161,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldBeEmpty();
@@ -176,7 +173,7 @@ public sealed class FindCalleesShould
[InlineData("int Value { get; }", "public int Value => Helper();", "_ = service.Value;")]
[InlineData("int Value { get; }", "int IService.Value => Helper();", "_ = service.Value;")]
[InlineData("int this[int index] { get; }", "public int this[int index] => Helper();", "_ = service[0];")]
public void FollowSourceImplementationsOfInterfaces(string contract, string implementation, string access)
public async Task FollowSourceImplementationsOfInterfaces(string contract, string implementation, string access)
{
// Arrange
var source = $$"""
@@ -190,7 +187,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -199,7 +196,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void NotListTheAbstractMemberOnlyTheImplementation()
public async Task NotListTheAbstractMemberOnlyTheImplementation()
{
// Arrange
const string source = """
@@ -212,7 +209,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -220,7 +217,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void FollowImplementationsOfInterfacesFromMetadata()
public async Task FollowImplementationsOfInterfacesFromMetadata()
{
// Arrange
const string source = """
@@ -233,7 +230,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -245,7 +242,7 @@ public sealed class FindCalleesShould
[Theory]
[InlineData("service.Run(); Run();")]
[InlineData("Run(); service.Run();")]
public void PreferDirectCallsOverInterfaceLinks(string body)
public async Task PreferDirectCallsOverInterfaceLinks(string body)
{
// Arrange
var source = $$"""
@@ -258,7 +255,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -266,7 +263,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void IgnoreInterfaceImplementationsWithoutPropertyBodies()
public async Task IgnoreInterfaceImplementationsWithoutPropertyBodies()
{
// Arrange
const string source = """
@@ -279,7 +276,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldBeEmpty();
@@ -293,7 +290,7 @@ public sealed class FindCalleesShould
"public override int this[int index] => Helper();",
"_ = service[0];"
)]
public void FollowOverridesOfAbstractMembers(string contract, string implementation, string access)
public async Task FollowOverridesOfAbstractMembers(string contract, string implementation, string access)
{
// Arrange
var source = $$"""
@@ -307,7 +304,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -316,7 +313,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void FollowOverridesThroughIntermediateAbstractClasses()
public async Task FollowOverridesThroughIntermediateAbstractClasses()
{
// Arrange
const string source = """
@@ -330,7 +327,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -340,7 +337,7 @@ public sealed class FindCalleesShould
[Theory]
[InlineData("service.Run(); Run();")]
[InlineData("Run(); service.Run();")]
public void PreferDirectCallsOverAbstractDispatch(string body)
public async Task PreferDirectCallsOverAbstractDispatch(string body)
{
// Arrange
var source = $$"""
@@ -353,7 +350,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -361,7 +358,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void FollowVirtualMemberImplementations()
public async Task FollowVirtualMemberImplementations()
{
// Arrange
const string source = """
@@ -374,7 +371,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start]
@@ -384,7 +381,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void NotFollowVirtualMemberImplementation_WhenOverrideIsCalledDirectly()
public async Task NotFollowVirtualMemberImplementation_WhenOverrideIsCalledDirectly()
{
// Arrange
const string source = """
@@ -397,7 +394,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var call = calls[start].ShouldHaveSingleItem();
@@ -408,7 +405,7 @@ public sealed class FindCalleesShould
[Theory]
[InlineData("new Service()")]
[InlineData("new()")]
public void FollowConstructorsAndTheirMethodCalls(string creation)
public async Task FollowConstructorsAndTheirMethodCalls(string creation)
{
// Arrange
var source = $$"""
@@ -424,7 +421,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var constructor = calls[start].ShouldHaveSingleItem();
@@ -436,7 +433,7 @@ public sealed class FindCalleesShould
[Theory]
[InlineData("this")]
[InlineData("base")]
public void FollowChainedConstructors(string initializer)
public async Task FollowChainedConstructors(string initializer)
{
// Arrange
var source = $$"""
@@ -457,7 +454,7 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
var constructor = calls[start].ShouldHaveSingleItem();
@@ -470,7 +467,7 @@ public sealed class FindCalleesShould
}
[Fact]
public void IgnoreConstructorsWithoutSourceDeclarations()
public async Task IgnoreConstructorsWithoutSourceDeclarations()
{
// Arrange
const string source = """
@@ -482,22 +479,9 @@ public sealed class FindCalleesShould
""";
// Act
var (start, calls) = FindCallees(source);
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldBeEmpty();
}
private static (ISymbol, IImmutableDictionary<ISymbol, IImmutableList<Call>>) FindCallees(string source)
{
var compilation = CSharpCompilation.Create(
"Example",
[CSharpSyntaxTree.ParseText(source)],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ShouldBeEmpty();
var start = compilation.GetTypeByMetadataName("Example")!.GetMembers("Start").Single();
return (start, CalleeBuilder.FindCallees([compilation], start, CancellationToken.None));
}
}
core/tests/CallGraphBuilderTests/FindCallersShould.cs
+325
-0
diff --git a/core/tests/CallGraphBuilderTests/FindCallersShould.cs b/core/tests/CallGraphBuilderTests/FindCallersShould.cs
new file mode 100644
index 0000000..e500184
@@ -0,0 +1,325 @@
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Shouldly;
using Xunit;
namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class FindCallersShould
{
[Theory]
[InlineData("_ = Start;", "int Start { get => 1; set { } }")]
[InlineData("Start = 2;", "int Start { get => 1; set { } }")]
public async Task FindPropertyAccesses(string access, string declaration)
{
// Arrange
var source = $$"""
class Example
{
{{declaration}}
void Caller() { {{access}} }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var caller = callers[start].ShouldHaveSingleItem();
caller.Target.Name.ShouldBe("Caller");
caller.LinkKind.ShouldBe(LinkKind.Direct);
}
[Theory]
[InlineData("_ = this[0];", "int this[int index] => Start(index);")]
[InlineData("this[0] = 1;", "int this[int index] { get => index; set => Start(index + value); }")]
public async Task FindIndexer(string access, string declaration)
{
// Arrange
var source = $$"""
class Example
{
int Start(int index) => 1;
{{declaration}}
void Caller() { {{access}} }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var indexer = callers[start].ShouldHaveSingleItem();
var caller = callers[indexer.Target].ShouldHaveSingleItem();
caller.Target.Name.ShouldBe("Caller");
caller.LinkKind.ShouldBe(LinkKind.Direct);
}
[Theory]
[InlineData("get { return Start(); }")]
[InlineData("get => Start();")]
[InlineData("set { Start(); }")]
public async Task AttributeAccessorCallsToTheirProperty(string accessor)
{
// Arrange
var source = $$"""
class Example
{
int Value { {{accessor}} }
int Start() => 1;
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
callers[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
}
[Theory]
[InlineData("new Example()")]
[InlineData("new()")]
public async Task FindConstructorCalls(string creation)
{
// Arrange
var source = $$"""
class Example
{
public Example() { Start(); }
void Start() { }
static void Caller() { Example example = {{creation}}; }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var constructor = callers[start].ShouldHaveSingleItem().Target;
constructor.ShouldBeAssignableTo<IMethodSymbol>().MethodKind.ShouldBe(MethodKind.Constructor);
callers[constructor].ShouldHaveSingleItem().Target.Name.ShouldBe("Caller");
}
[Fact]
public async Task FindCallsThroughMetadataInterfaces()
{
// Arrange
const string source = """
class Example : System.IDisposable
{
public void Dispose() { Start(); }
void Start() { }
void Caller(System.IDisposable service) { service.Dispose(); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var dispose = callers[start].ShouldHaveSingleItem().Target;
var caller = callers[dispose].ShouldHaveSingleItem();
caller.Target.Name.ShouldBe("Caller");
caller.LinkKind.ShouldBe(LinkKind.Abstract);
}
[Fact]
public async Task IgnoreDirectCallsToOtherImplementations()
{
// Arrange
const string source = """
interface IService { void Run(); }
class Other : IService { public void Run() { } }
class Example : IService
{
public void Run() { Start(); }
void Start() { }
void Caller(Other other) { other.Run(); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var run = callers[start].ShouldHaveSingleItem().Target;
callers[run].ShouldBeEmpty();
}
[Fact]
public async Task DeduplicateCallersAndStopAtCycles()
{
// Arrange
const string source = """
class Example
{
void Start() { Caller(); }
void Caller() { Start(); Start(); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var caller = callers[start].ShouldHaveSingleItem().Target;
callers[caller].ShouldHaveSingleItem().Target.ShouldBe(start);
callers.Count.ShouldBe(2);
}
[Fact]
public async Task FindCallsThroughVirtualBaseMembers()
{
// Arrange
const string source = """
class Base { public virtual void Run() { } }
class Example : Base
{
public override void Run() { Start(); }
void Start() { }
void Caller(Base service) { service.Run(); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var run = callers[start].ShouldHaveSingleItem().Target;
var caller = callers[run].ShouldHaveSingleItem();
caller.Target.Name.ShouldBe("Caller");
caller.LinkKind.ShouldBe(LinkKind.Abstract);
}
[Fact]
public async Task FindCallsInsideLambdas()
{
// Arrange
const string source = """
class Example
{
void Start() { }
void Caller() { System.Action action = () => Start(); action(); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
callers[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Caller");
}
[Fact]
public async Task ReturnAnEmptyListWhenThereAreNoCallers()
{
// Arrange
const string source = """
class Example { void Start() { } }
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
callers[start].ShouldBeEmpty();
}
[Fact]
public async Task FollowCallers()
{
// Arrange
var source = """
class Example
{
void TwoUp() { OneUp(); }
void OneUp() { Start(); }
void Start() { }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var oneUp = callers[start].ShouldHaveSingleItem().Target;
oneUp.Name.ShouldBe("OneUp");
var twoUp = callers[oneUp].ShouldHaveSingleItem().Target;
twoUp.Name.ShouldBe("TwoUp");
}
[Fact]
public async Task FindExpressionBodiedProperties()
{
// Arrange
var source = """
class Example
{
int Value => Start();
int Start() => 42;
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
callers[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Value");
}
[Fact]
public async Task FindCallsToTheAbstractMethodThatTheOriginImplements()
{
// Arrange
var source = """
abstract class Base
{
public abstract void Run();
}
class Example : Base
{
void ShouldBeFound(Base b) { b.Run(); }
public override void Run() { Start(); }
void Start() { }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var concreteRun = callers[start].ShouldHaveSingleItem();
concreteRun.Target.ContainingType.Name.ShouldBe("Example");
var shouldBeFound = callers[concreteRun.Target].ShouldHaveSingleItem();
shouldBeFound.Target.Name.ShouldBe("ShouldBeFound");
shouldBeFound.LinkKind.ShouldBe(LinkKind.Abstract);
}
[Theory]
[InlineData("service.Run(); Run();")]
[InlineData("Run(); service.Run();")]
public async Task PreferDirectCallsOverInterfaceLinks(string body)
{
// Arrange
var source = $$"""
interface IService { void Run(); }
class Example : IService
{
void ShouldBeFound(IService service) { {{body}} }
public void Run() { Start(); }
void Start() { }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var concreteRun = callers[start].ShouldHaveSingleItem();
concreteRun.Target.ContainingType.Name.ShouldBe("Example");
var shouldBeFound = callers[concreteRun.Target].ShouldHaveSingleItem();
shouldBeFound.Target.Name.ShouldBe("ShouldBeFound");
shouldBeFound.LinkKind.ShouldBe(LinkKind.Direct);
}
}
core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
+67
-0
diff --git a/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs b/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
new file mode 100644
index 0000000..1818f6c
@@ -0,0 +1,67 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Shouldly;
namespace Cfa.Tests.CallGraphBuilderTests;
internal static class SourceToCallGraphHelper
{
public static async Task<(ISymbol, IImmutableDictionary<ISymbol, IImmutableList<Call>>)> FindCallees(
this string source
)
{
var (start, callGraphBuilder) = await source.GetStartSymbolAndCallGraphBuilder();
callGraphBuilder.FindCallees(start, CancellationToken.None);
return (start, callGraphBuilder.ReachableFromSymbols.ToImmutable());
}
public static async Task<(ISymbol, IImmutableDictionary<ISymbol, IImmutableList<Call>>)> FindCallers(
this string source
)
{
var (start, callGraphBuilder) = await source.GetStartSymbolAndCallGraphBuilder();
await callGraphBuilder.FindCallers(start, CancellationToken.None);
return (start, callGraphBuilder.ReachesToSymbols.ToImmutable());
}
private static async Task<(ISymbol, CallGraphBuilder)> GetStartSymbolAndCallGraphBuilder(this string source)
{
var workspace = new AdhocWorkspace();
var projectInfo = ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
name: "Example",
assemblyName: "Example",
language: LanguageNames.CSharp,
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
metadataReferences: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]
);
workspace
.TryApplyChanges(
workspace
.AddSolution(SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Default))
.AddProject(projectInfo)
)
.ShouldBeTrue();
workspace.AddDocument(projectInfo.Id, "Example.cs", SourceText.From(source));
var project = workspace.CurrentSolution.GetProject(projectInfo.Id).ShouldNotBeNull();
var compilation = (await project.GetCompilationAsync()).ShouldNotBeNull();
compilation.GetDiagnostics().ShouldBeEmpty();
var start = compilation
.GetTypeByMetadataName("Example")
.ShouldNotBeNull()
.GetMembers("Start")
.ShouldHaveSingleItem();
var callGraphBuilder = new CallGraphBuilder(
workspace.CurrentSolution,
[compilation],
[.. compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type).OfType<INamedTypeSymbol>()]
);
return (start, callGraphBuilder);
}
}