Commit: 0b5e5e6
Parent: 6ae944c

Introduce reachability analysis

Mårten Åsberg committed on 2026-03-08 at 20:57
Core/CompilationExtensions.cs +14 -0
diff --git a/Core/CompilationExtensions.cs b/Core/CompilationExtensions.cs
new file mode 100644
index 0000000..0e3b2b8
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Reacher;
public static class CompilationExtensions
{
public static IEnumerable<IMethodSymbol> GetPublicMembers(this Compilation compilation)
{
var collector = new PublicMembersCollector();
compilation.Assembly.Accept(collector);
return collector.PublicMembers;
}
}
Core/Core.csproj +9 -0
diff --git a/Core/Core.csproj b/Core/Core.csproj
new file mode 100644
index 0000000..955cf40
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Reacher</AssemblyName>
<RootNamespace>Reacher</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
</ItemGroup>
</Project>
Core/PublicMembersCollector.cs +52 -0
diff --git a/Core/PublicMembersCollector.cs b/Core/PublicMembersCollector.cs
new file mode 100644
index 0000000..981033d
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Reacher;
internal class PublicMembersCollector : SymbolVisitor
{
private readonly HashSet<IMethodSymbol> publicMembers = [];
public IEnumerable<IMethodSymbol> PublicMembers => publicMembers;
public override void VisitAssembly(IAssemblySymbol symbol)
{
symbol.GlobalNamespace.Accept(this);
}
public override void VisitNamespace(INamespaceSymbol symbol)
{
foreach (var namespaceOrType in symbol.GetMembers())
{
namespaceOrType.Accept(this);
}
}
public override void VisitNamedType(INamedTypeSymbol type)
{
if (type.DeclaredAccessibility is not Accessibility.Public)
{
return;
}
foreach (var member in type.GetMembers())
{
member.Accept(this);
}
foreach (var nestedType in type.GetTypeMembers())
{
nestedType.Accept(this);
}
}
public override void VisitMethod(IMethodSymbol member)
{
if (member.DeclaredAccessibility is not Accessibility.Public)
{
return;
}
publicMembers.Add(member);
}
}
Core/ReachabilityAnalysis.cs +71 -0
diff --git a/Core/ReachabilityAnalysis.cs b/Core/ReachabilityAnalysis.cs
new file mode 100644
index 0000000..f79a18b
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Reacher;
public sealed class ReachabilityAnalysis(IReadOnlyCollection<Compilation> compilations)
{
private readonly HashSet<IMethodSymbol> reachableMembers = [];
public IReadOnlySet<IMethodSymbol> ReachableMembers => reachableMembers;
internal void Analyze(IMethodSymbol member, CancellationToken cancellationToken)
{
if (!TryGetAssociatedCompilation(member, out var compilation) || !reachableMembers.Add(member))
{
return;
}
foreach (var declaration in member.DeclaringSyntaxReferences)
{
AnalyzeDeclaration(compilation, declaration, cancellationToken);
}
}
private bool TryGetAssociatedCompilation(IMethodSymbol member, [NotNullWhen(true)] out Compilation? compilation)
{
compilation = compilations.FirstOrDefault(c =>
member.DeclaringSyntaxReferences.Any(d => c.ContainsSyntaxTree(d.SyntaxTree))
);
return compilation is not null;
}
private void AnalyzeDeclaration(
Compilation compilation,
SyntaxReference declaration,
CancellationToken cancellationToken
)
{
var originalNode = declaration.GetSyntax(cancellationToken);
var identifiers = originalNode.DescendantNodes(ShouldDescendInto(originalNode)).OfType<IdentifierNameSyntax>();
var semanticModel = compilation.GetSemanticModel(declaration.SyntaxTree);
foreach (var identifier in identifiers)
{
if (!semanticModel.IsReachable(identifier))
{
continue;
}
if (semanticModel.GetSymbolInfo(identifier, cancellationToken) is not { Symbol: IMethodSymbol member })
{
continue;
}
Analyze(member, cancellationToken);
}
}
private static Func<SyntaxNode, bool> ShouldDescendInto(SyntaxNode originalNode) =>
syntaxNode =>
syntaxNode == originalNode
|| !(
syntaxNode.IsKind(SyntaxKind.LocalFunctionStatement)
|| syntaxNode.IsKind(SyntaxKind.SimpleLambdaExpression)
|| syntaxNode.IsKind(SyntaxKind.ParenthesizedLambdaExpression)
);
}
Core/SolutionExtensions.cs +62 -0
diff --git a/Core/SolutionExtensions.cs b/Core/SolutionExtensions.cs
new file mode 100644
index 0000000..acaac14
@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
namespace Reacher;
public static class SolutionExtensions
{
public static async Task<ReachabilityAnalysis> AnalyzeReachabilityFromEntryPoints(
this Solution solution,
CancellationToken cancellationToken
)
{
var compilations = await solution.GetCompilations(cancellationToken);
return AnalyzeReachability(
compilations,
[.. compilations.Select(c => c.GetEntryPoint(cancellationToken)).OfType<IMethodSymbol>()],
cancellationToken
);
}
public static async Task<ReachabilityAnalysis> AnalyzeReachabilityFromPublicMembers(
this Solution solution,
CancellationToken cancellationToken
)
{
var compilations = await solution.GetCompilations(cancellationToken);
return AnalyzeReachability(
compilations,
[.. compilations.SelectMany(CompilationExtensions.GetPublicMembers)],
cancellationToken
);
}
private static ReachabilityAnalysis AnalyzeReachability(
IReadOnlyCollection<Compilation> compilations,
IEnumerable<IMethodSymbol> entryPoints,
CancellationToken cancellationToken
)
{
var analysis = new ReachabilityAnalysis(compilations);
foreach (var entryPoint in entryPoints)
{
analysis.Analyze(entryPoint, cancellationToken);
}
return analysis;
}
private static async Task<Compilation[]> GetCompilations(
this Solution solution,
CancellationToken cancellationToken
) =>
await solution
.Projects.ToAsyncEnumerable()
.Select(async (p, ct) => await p.GetCompilationAsync(ct))
.OfType<Compilation>()
.ToArrayAsync(cancellationToken);
}
Core/SyntaxNodeStatementReachability.cs +37 -0
diff --git a/Core/SyntaxNodeStatementReachability.cs b/Core/SyntaxNodeStatementReachability.cs
new file mode 100644
index 0000000..8a65a3e
@@ -0,0 +1,37 @@
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Reacher;
internal static class SyntaxNodeStatementReachability
{
public static bool IsReachable(this SemanticModel semanticModel, SyntaxNode syntaxNode)
{
var statement = syntaxNode
.AncestorsAndSelf()
.TakeWhile(AscendWithinMember)
.OfType<StatementSyntax>()
.FirstOrDefault();
if (statement is null)
{
return syntaxNode
.AncestorsAndSelf()
.TakeWhile(AscendWithinMember)
.Any(n => n.IsKind(SyntaxKind.ArrowExpressionClause));
}
var controlFlowAnalysis = semanticModel.AnalyzeControlFlow(statement);
return controlFlowAnalysis?.StartPointIsReachable is true;
}
private static bool AscendWithinMember(SyntaxNode syntax) =>
!(
syntax.IsKind(SyntaxKind.LocalFunctionStatement)
|| syntax.IsKind(SyntaxKind.MethodDeclaration)
|| syntax.IsKind(SyntaxKind.PropertyDeclaration)
|| syntax.IsKind(SyntaxKind.IndexerDeclaration)
|| syntax.IsKind(SyntaxKind.ConstructorDeclaration)
);
}
Directory.Build.props +1 -0
diff --git a/Directory.Build.props b/Directory.Build.props
index 128321e..0ff00cb 100644
@@ -3,5 +3,6 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>$(WarningsAsErrors);IDE0005</WarningsAsErrors>
</PropertyGroup>
</Project>
Directory.Packages.props +2 -0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5e8e129..fda5633 100644
@@ -12,7 +12,9 @@
<ItemGroup>
<PackageVersion Include="Microsoft.Build.Framework" Version="18.3.3" />
<PackageVersion Include="Microsoft.Build.Locator" Version="1.11.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.0.0" />
<PackageVersion Include="MSTest" Version="4.0.1" />
</ItemGroup>
</Project>
Poc/Poc.csproj +3 -0
diff --git a/Poc/Poc.csproj b/Poc/Poc.csproj
index 4990fe3..2a2e636 100644
@@ -10,4 +10,7 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
</Project>
Poc/Program.cs +23 -40
diff --git a/Poc/Program.cs b/Poc/Program.cs
index bf57989..214988b 100644
@@ -1,54 +1,37 @@
using System;
using System.Linq;
using System.Reflection;
using System.IO;
using System.Runtime.CompilerServices;
using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;
using Reacher;
MSBuildLocator.RegisterDefaults();
using var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(@"..\Reacher.slnx");
var solution = await workspace.OpenSolutionAsync(GetSolutionPath());
var project = solution.Projects.First(p => p.AssemblyName == Assembly.GetExecutingAssembly().GetName().Name);
Noop();
var compilation = await project.GetCompilationAsync();
if (compilation is null)
{
Console.WriteLine($"Cannot get compilation for {project.Name}");
return;
}
var analysis = await solution.AnalyzeReachabilityFromEntryPoints(default);
var entryPoint = compilation.GetEntryPoint(default);
if (entryPoint is null)
foreach (var member in analysis.ReachableMembers)
{
Console.WriteLine($"No entry point in {project.Name}");
return;
#pragma warning disable CS0162 // Intentional to test reachability
Console.WriteLine($"No entry point in {project.Name}");
#pragma warning restore CS0162
Console.WriteLine(member);
}
foreach (var declaration in entryPoint.DeclaringSyntaxReferences)
{
Console.WriteLine($"Entry point was declared at {declaration.SyntaxTree.GetLineSpan(declaration.Span)}");
var semanticModel = compilation.GetSemanticModel(declaration.SyntaxTree, ignoreAccessibility: true);
var statements = declaration.GetSyntax().DescendantNodes().OfType<StatementSyntax>();
foreach (var statement in statements)
{
var controlFlowAnalysis = semanticModel.AnalyzeControlFlow(statement);
Console.WriteLine(
$"Is {statement.Kind()} at {declaration.SyntaxTree.GetLineSpan(statement.Span)} reachable: {controlFlowAnalysis?.StartPointIsReachable}"
);
if (false && true)
{
#pragma warning disable CS0162 // Intentional to test reachability
Console.WriteLine("Hello");
return;
#pragma warning disable CS0162 // Intentionally unreachable
Unreachable();
#pragma warning restore CS0162
}
}
}
static string GetSolutionPath([CallerFilePath] string sourcePath = null!) =>
Path.GetFullPath(Path.Join(Path.GetDirectoryName(sourcePath), @"..\Reacher.slnx"));
static void Noop() => TransitiveNoop();
static void TransitiveNoop() { }
static void Unreachable() => Unreachable();
static void Unused() => Unused();
Reacher.slnx +1 -0
diff --git a/Reacher.slnx b/Reacher.slnx
index 991cee3..3b918fc 100644
@@ -1,3 +1,4 @@
<Solution>
<Project Path="Core/Core.csproj" />
<Project Path="Poc/Poc.csproj" />
</Solution>