Created
May 27, 2025 12:06
-
-
Save mafshin/e0f8b9095845631ee2afdfb2333c16b5 to your computer and use it in GitHub Desktop.
C# Unused Variables Scanner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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