Skip to content

Instantly share code, notes, and snippets.

@mafshin
Created May 27, 2025 12:06
Show Gist options
  • Save mafshin/e0f8b9095845631ee2afdfb2333c16b5 to your computer and use it in GitHub Desktop.
Save mafshin/e0f8b9095845631ee2afdfb2333c16b5 to your computer and use it in GitHub Desktop.
C# Unused Variables Scanner
using System;
using System.IO;
using System.Linq;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Diagnostics;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace UnusedSymbolAnalyzer
{
class Program
{
static async Task<int> Main(string[] args)
{
if (args.Length < 1)
{
Console.WriteLine("Usage: UnusedSymbolAnalyzer <gitCommitHash> [repositoryPath] [outputCsvPath]");
return 1;
}
var commitHash = args[0];
var repoPath = args.Length >= 2 ? args[1] : Directory.GetCurrentDirectory();
var csvPath = args.Length >= 3 ? args[2] : Path.Combine(Directory.GetCurrentDirectory(), "unused_symbols.csv");
// Gather changed .cs files since the given commit (including that commit)
var csFiles = GetChangedFiles(commitHash, repoPath)
.Where(f => File.Exists(f))
.ToArray();
if (csFiles.Length == 0)
{
Console.WriteLine($"No .cs files changed since commit {commitHash}.");
return 0;
}
var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId("AnalyzerProject");
var projectInfo = ProjectInfo.Create(
projectId,
VersionStamp.Default,
"AnalyzerProject",
"AnalyzerProject",
LanguageNames.CSharp)
.WithMetadataReferences(new[]
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location)
});
workspace.AddProject(projectInfo);
foreach (var file in csFiles)
{
var sourceText = SourceText.From(File.ReadAllText(file));
workspace.AddDocument(projectId, file, sourceText, filePath: file);
}
var project = workspace.CurrentSolution.GetProject(projectId);
var compilation = await project.GetCompilationAsync();
var declaredSymbols = new ConcurrentDictionary<ISymbol, Location>(SymbolEqualityComparer.Default);
var referencedSymbols = new ConcurrentDictionary<ISymbol, byte>(SymbolEqualityComparer.Default);
// Collect declarations in parallel
Parallel.ForEach(compilation.SyntaxTrees, tree =>
{
var model = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
// Fields
foreach (var field in root.DescendantNodes().OfType<FieldDeclarationSyntax>())
{
foreach (var variable in field.Declaration.Variables)
{
var symbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, variable.GetLocation());
}
}
// Local variables
foreach (var local in root.DescendantNodes().OfType<LocalDeclarationStatementSyntax>())
{
foreach (var variable in local.Declaration.Variables)
{
var symbol = model.GetDeclaredSymbol(variable) as ILocalSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, variable.GetLocation());
}
}
// For statements
foreach (var forStmt in root.DescendantNodes().OfType<ForStatementSyntax>())
{
if (forStmt.Declaration != null)
{
foreach (var variable in forStmt.Declaration.Variables)
{
var symbol = model.GetDeclaredSymbol(variable) as ILocalSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, variable.GetLocation());
}
}
}
// Foreach statements
foreach (var foreachStmt in root.DescendantNodes().OfType<ForEachStatementSyntax>())
{
var symbol = model.GetDeclaredSymbol(foreachStmt) as ILocalSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, foreachStmt.Identifier.GetLocation());
}
// Using declarations
foreach (var usingStmt in root.DescendantNodes().OfType<UsingStatementSyntax>())
{
if (usingStmt.Declaration != null)
{
foreach (var variable in usingStmt.Declaration.Variables)
{
var symbol = model.GetDeclaredSymbol(variable) as ILocalSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, variable.GetLocation());
}
}
}
// Catch declarations
foreach (var catchDecl in root.DescendantNodes().OfType<CatchDeclarationSyntax>())
{
var symbol = model.GetDeclaredSymbol(catchDecl) as ILocalSymbol;
if (symbol != null)
declaredSymbols.TryAdd(symbol, catchDecl.Identifier.GetLocation());
}
});
// Collect references in parallel
Parallel.ForEach(compilation.SyntaxTrees, tree =>
{
var model = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
foreach (var id in root.DescendantNodes().OfType<IdentifierNameSyntax>())
{
var symbol = model.GetSymbolInfo(id).Symbol;
if (symbol != null && declaredSymbols.ContainsKey(symbol))
referencedSymbols.TryAdd(symbol, 0);
}
});
// Determine unused symbols
var unused = declaredSymbols.Keys
.Where(s => !referencedSymbols.ContainsKey(s))
.OrderBy(s => declaredSymbols[s].SourceTree.FilePath)
.ThenBy(s => declaredSymbols[s].GetLineSpan().StartLinePosition.Line)
.ToList();
// Output to console
foreach (var symbol in unused)
{
var loc = declaredSymbols[symbol].GetLineSpan();
Console.WriteLine($"{loc.Path}({loc.StartLinePosition.Line + 1}): {symbol.Name}");
}
// Write CSV
using (var writer = new StreamWriter(csvPath))
{
writer.WriteLine("File,Line,Name");
foreach (var symbol in unused)
{
var loc = declaredSymbols[symbol].GetLineSpan();
writer.WriteLine($"\"{loc.Path}\",{loc.StartLinePosition.Line + 1},\"{symbol.Name}\"");
}
}
Console.WriteLine($"CSV written to {csvPath}");
return 0;
}
private static IEnumerable<string> GetChangedFiles(string commit, string repoPath)
{
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Files changed in the specified commit
var commitInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = $"diff-tree --no-commit-id --name-only -r {commit}",
WorkingDirectory = repoPath,
RedirectStandardOutput = true,
UseShellExecute = false
};
using (var proc = Process.Start(commitInfo))
{
string? line;
while ((line = proc.StandardOutput.ReadLine()) != null)
{
if (line.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
files.Add(Path.Combine(repoPath, line.Trim().Replace('/', Path.DirectorySeparatorChar)));
}
proc.WaitForExit();
}
// Files changed since the commit up to HEAD
var diffInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = $"diff --name-only {commit} HEAD",
WorkingDirectory = repoPath,
RedirectStandardOutput = true,
UseShellExecute = false
};
using (var proc = Process.Start(diffInfo))
{
string? line;
while ((line = proc.StandardOutput.ReadLine()) != null)
{
if (line.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
files.Add(Path.Combine(repoPath, line.Trim().Replace('/', Path.DirectorySeparatorChar)));
}
proc.WaitForExit();
}
return files;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment