From a32cbbaae9c433ab30b4e7c9088564ca5bc39499 Mon Sep 17 00:00:00 2001 From: ATrefzer <36333177+ATrefzer@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:01:46 +0200 Subject: [PATCH 01/10] Analyzer draft --- .../DeadCode/Analyzer.cs | 65 ++++ .../Presentation/DeadCodeRowViewModel.cs | 71 ++++ .../Presentation/DeadCodeViewModel.cs | 113 ++++++ .../Resources/Strings.Designer.cs | 108 ++++++ .../Resources/Strings.resx | 36 ++ .../Algorithms/DeadCode/DeadCodeAnalysis.cs | 332 ++++++++++++++++++ .../Algorithms/DeadCode/DeadCodeFinding.cs | 57 +++ .../Features/Analyzers/AnalyzerManager.cs | 5 + Documentation/dead-code.md | 146 ++++++++ README.md | 17 + Tests/Helper/TestCodeGraph.cs | 16 + .../DeadCode/DeadCodeAnalysisTests.cs | 263 ++++++++++++++ .../UnitTests/DeadCode/DeadCodeParseTests.cs | 100 ++++++ 13 files changed, 1329 insertions(+) create mode 100644 CSharpCodeAnalyst.Analyzers/DeadCode/Analyzer.cs create mode 100644 CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs create mode 100644 CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs create mode 100644 CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeAnalysis.cs create mode 100644 CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeFinding.cs create mode 100644 Documentation/dead-code.md create mode 100644 Tests/UnitTests/DeadCode/DeadCodeAnalysisTests.cs create mode 100644 Tests/UnitTests/DeadCode/DeadCodeParseTests.cs diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Analyzer.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Analyzer.cs new file mode 100644 index 00000000..a49672e9 --- /dev/null +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Analyzer.cs @@ -0,0 +1,65 @@ +using CSharpCodeAnalyst.Analyzers.DeadCode.Presentation; +using CSharpCodeAnalyst.Analyzers.Resources; +using CSharpCodeAnalyst.AnalyzerSdk.Contracts; +using CSharpCodeAnalyst.AnalyzerSdk.Messages; +using CSharpCodeAnalyst.AnalyzerSdk.Notifications; +using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; + +namespace CSharpCodeAnalyst.Analyzers.DeadCode; + +/// +/// Lists the code nobody references any more - the topmost element of every dead subtree, together +/// with the hint that explains why it might still be alive. +/// +public class Analyzer : IAnalyzer +{ + private readonly IPublisher _messaging; + private readonly IUserNotification _userNotification; + + public Analyzer(IPublisher messaging, IUserNotification userNotification) + { + _messaging = messaging; + _userNotification = userNotification; + } + + public string Id { get; } = "DeadCode"; + public string Name { get; } = Strings.Analyzer_DeadCode_Label; + public string Description { get; set; } = Strings.Analyzer_DeadCode_Tooltip; + + public void Analyze(CodeGraph.Graph.CodeGraph graph) + { + var findings = DeadCodeAnalysis.Calculate(graph); + + if (findings.Count == 0) + { + _userNotification.ShowSuccess(Strings.Analyzer_DeadCode_NoData); + return; + } + + var vm = new DeadCodeViewModel(findings, _messaging); + _messaging.Publish(new ShowTabularDataRequest(Id, Name, vm)); + } + + public string? GetPersistentData() + { + // No configuration or state to persist. + return null; + } + + public void SetPersistentData(string? data) + { + // No configuration or state to persist. + } + + public bool IsDirty() + { + return false; + } + + public event EventHandler? DataChanged; + + protected virtual void OnDataChanged() + { + DataChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs new file mode 100644 index 00000000..28ff0eae --- /dev/null +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs @@ -0,0 +1,71 @@ +using CSharpCodeAnalyst.Analyzers.Resources; +using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData; +using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.Analyzers.DeadCode.Presentation; + +public class DeadCodeRowViewModel : TableRow +{ + /// Beyond this many related members the hint only states the count - the cell has to stay readable. + private const int MaxNamedRelatedMembers = 3; + + internal DeadCodeRowViewModel(DeadCodeFinding finding) + { + Element = finding.Element; + Name = finding.Element.FullName; + Kind = finding.Element.ElementType.ToString(); + Hint = FormatHint(finding); + } + + /// The underlying graph node, used to jump to the source and to add it to the Code Explorer. + public CodeElement Element { get; } + + public string Name { get; } + public string Kind { get; } + + /// Why the element might be alive despite having no visible reference. Empty means: no doubts. + public string Hint { get; } + + private static string FormatHint(DeadCodeFinding finding) + { + var parts = new List(); + + if (finding.Hints.HasFlag(DeadCodeHint.EntryPoint)) + { + parts.Add(Strings.DeadCode_Hint_EntryPoint); + } + + if (finding.Hints.HasFlag(DeadCodeHint.TestCode)) + { + parts.Add(Strings.DeadCode_Hint_TestCode); + } + + if (finding.Hints.HasFlag(DeadCodeHint.ContractNeverCalled)) + { + parts.Add(string.Format(Strings.DeadCode_Hint_ContractNeverCalled, FormatRelated(finding))); + } + + if (finding.Hints.HasFlag(DeadCodeHint.ImplementsDeadContract)) + { + parts.Add(string.Format(Strings.DeadCode_Hint_ImplementsDeadContract, FormatRelated(finding))); + } + + if (finding.Hints.HasFlag(DeadCodeHint.Attributed)) + { + parts.Add(string.Format(Strings.DeadCode_Hint_Attributed, string.Join(", ", finding.Attributes))); + } + + return string.Join("; ", parts); + } + + private static string FormatRelated(DeadCodeFinding finding) + { + if (finding.RelatedMembers.Count > MaxNamedRelatedMembers) + { + return string.Format(Strings.DeadCode_Hint_RelatedCount, finding.RelatedMembers.Count); + } + + return string.Join(", ", finding.RelatedMembers.Select(m => m.FullName)); + } +} diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs new file mode 100644 index 00000000..9c2cdba6 --- /dev/null +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs @@ -0,0 +1,113 @@ +using System.Collections.ObjectModel; +using System.Windows; +using CSharpCodeAnalyst.Analyzers.Resources; +using CSharpCodeAnalyst.AnalyzerSdk.Contracts; +using CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData; +using CSharpCodeAnalyst.AnalyzerSdk.Messages; +using CSharpCodeAnalyst.AnalyzerSdk.Search; +using CSharpCodeAnalyst.AnalyzerSdk.Wpf; +using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; + +namespace CSharpCodeAnalyst.Analyzers.DeadCode.Presentation; + +internal class DeadCodeViewModel : Table +{ + private readonly IPublisher _messaging; + private readonly ObservableCollection _rows; + + internal DeadCodeViewModel(List findings, IPublisher messaging) + { + _messaging = messaging; + var rows = findings.Select(f => new DeadCodeRowViewModel(f)); + _rows = new ObservableCollection(rows); + } + + public override bool CanFilter => true; + + public override IEnumerable GetColumns() + { + return new List + { + new() + { + Type = ColumnType.Text, + Header = Strings.Column_DeadCode_Element, + PropertyName = nameof(DeadCodeRowViewModel.Name) + }, + new() + { + Type = ColumnType.Text, + Header = Strings.Column_DeadCode_Kind, + PropertyName = nameof(DeadCodeRowViewModel.Kind), + Width = 90 + }, + new() + { + // Empty means nothing speaks against deleting it - sorting brings those rows together. + Type = ColumnType.Text, + Header = Strings.Column_DeadCode_Hint, + PropertyName = nameof(DeadCodeRowViewModel.Hint) + } + }; + } + + public override ObservableCollection GetData() + { + return _rows; + } + + /// + /// Filters by element name using the same search expression as the Advanced Search + /// (supports camel-case, OR via '|', AND via spaces). + /// + public override ObservableCollection Filter(string searchText) + { + if (string.IsNullOrWhiteSpace(searchText)) + { + return _rows; + } + + var expression = SearchExpressionFactory.CreateSearchExpression(searchText); + var filtered = _rows + .Cast() + .Where(row => expression.Evaluate(row.Element)); + return new ObservableCollection(filtered); + } + + public override DataTemplate? GetRowDetailsTemplate() + { + return null; + } + + public override List GetCommands() + { + return + [ + new CommandDefinition + { + Header = Strings.JumpToCode, + Command = new WpfCommand(JumpToCode, CanJumpToCode) + }, + new CommandDefinition + { + Header = Strings.CopyToExplorerGraph_MenuItem, + Command = new WpfCommand(ShowInExplorer) + } + ]; + } + + private void ShowInExplorer(DeadCodeRowViewModel row) + { + _messaging.Publish(new AddNodeToGraphRequest(row.Element)); + } + + private static bool CanJumpToCode(DeadCodeRowViewModel row) + { + return row.Element.SourceLocations.Count > 0; + } + + private void JumpToCode(DeadCodeRowViewModel row) + { + _messaging.Publish(new OpenSourceLocationRequest(row.Element.SourceLocations[0])); + } +} diff --git a/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs b/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs index cd584a66..ce4d4497 100644 --- a/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs +++ b/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs @@ -257,6 +257,114 @@ public static string Analyzer_SystemMetrics_Tooltip { } } + /// + /// Looks up a localized string similar to Dead Code. + /// + public static string Analyzer_DeadCode_Label { + get { + return ResourceManager.GetString("Analyzer_DeadCode_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No unreferenced elements found. + /// + public static string Analyzer_DeadCode_NoData { + get { + return ResourceManager.GetString("Analyzer_DeadCode_NoData", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Finds elements nothing references any more. Only the topmost element of a dead subtree is listed; the hint column marks the cases that may still be used through XAML, reflection or a test runner.. + /// + public static string Analyzer_DeadCode_Tooltip { + get { + return ResourceManager.GetString("Analyzer_DeadCode_Tooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Element. + /// + public static string Column_DeadCode_Element { + get { + return ResourceManager.GetString("Column_DeadCode_Element", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Might still be used. + /// + public static string Column_DeadCode_Hint { + get { + return ResourceManager.GetString("Column_DeadCode_Hint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Kind. + /// + public static string Column_DeadCode_Kind { + get { + return ResourceManager.GetString("Column_DeadCode_Kind", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attributes: {0}. + /// + public static string DeadCode_Hint_Attributed { + get { + return ResourceManager.GetString("DeadCode_Hint_Attributed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Implemented but never called: {0}. + /// + public static string DeadCode_Hint_ContractNeverCalled { + get { + return ResourceManager.GetString("DeadCode_Hint_ContractNeverCalled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Entry point. + /// + public static string DeadCode_Hint_EntryPoint { + get { + return ResourceManager.GetString("DeadCode_Hint_EntryPoint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Implements unused contract: {0}. + /// + public static string DeadCode_Hint_ImplementsDeadContract { + get { + return ResourceManager.GetString("DeadCode_Hint_ImplementsDeadContract", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} members. + /// + public static string DeadCode_Hint_RelatedCount { + get { + return ResourceManager.GetString("DeadCode_Hint_RelatedCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Test code. + /// + public static string DeadCode_Hint_TestCode { + get { + return ResourceManager.GetString("DeadCode_Hint_TestCode", resourceCulture); + } + } + /// /// Looks up a localized string similar to Type Cohesion. /// diff --git a/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx b/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx index 1762903e..129e5770 100644 --- a/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx +++ b/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx @@ -203,6 +203,42 @@ No types found to rank + + + Dead Code + + + Finds elements nothing references any more. Only the topmost element of a dead subtree is listed; the hint column marks the cases that may still be used through XAML, reflection or a test runner. + + + No unreferenced elements found + + + Element + + + Kind + + + Might still be used + + + Entry point + + + Test code + + + Attributes: {0} + + + Implemented but never called: {0} + + + Implements unused contract: {0} + + + {0} members Type Cohesion diff --git a/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeAnalysis.cs b/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeAnalysis.cs new file mode 100644 index 00000000..20bdb08e --- /dev/null +++ b/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeAnalysis.cs @@ -0,0 +1,332 @@ +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; + +/// +/// Finds code nobody references any more. +/// +/// The rule is expressed over the subtree, not over the single element: an element is dead when no +/// relationship enters its subtree from the outside. That makes the obvious case work - a class +/// whose method is called from elsewhere is alive even though nothing names the class itself - and +/// it also stops a class from keeping itself alive: methods that only call each other are internal +/// to the subtree and prove nothing. Because a dead element implies a dead subtree, only the +/// topmost dead element of a subtree is reported. +/// +/// +/// Polymorphism is handled by propagating liveness instead of counting the edge as a reference. +/// "Implements" / "Overrides" point from the implementation to the contract, so an implementation +/// never has an incoming reference and a contract member always looks used. Both are wrong. We +/// therefore ignore those edges as references and instead push liveness the other way: a contract +/// member that is called keeps all its implementations (and their types) alive. A contract that is +/// never called dies together with its implementations, which is exactly the finding one wants. +/// Contracts from outside the analyzed code are the exception - we cannot see who calls them, so +/// the implementation is assumed alive. That assumption deliberately does not extend to the +/// containing type: a class whose only "use" is implementing IDisposable is still dead code. +/// +/// +/// Limitations, by construction: references the parser cannot see (XAML, reflection, dependency +/// injection, serialization) look like dead code - see . Accessibility +/// is not part of the graph, so the public API of a library cannot be treated as used. And because +/// this is the direct variant, an element stays alive when a dead element references it; only a +/// cascading analysis would collapse whole dead clusters. +/// +/// +public static class DeadCodeAnalysis +{ + /// + /// Attribute names (with and without the "Attribute" suffix) of the common test frameworks. A test + /// method is called by a runner, never from the code, so it always looks unreferenced. + /// + private static readonly HashSet TestAttributes = new(StringComparer.OrdinalIgnoreCase) + { + "Test", "TestAttribute", + "TestCase", "TestCaseAttribute", + "TestCaseSource", "TestCaseSourceAttribute", + "TestFixture", "TestFixtureAttribute", + "SetUp", "SetUpAttribute", + "TearDown", "TearDownAttribute", + "OneTimeSetUp", "OneTimeSetUpAttribute", + "OneTimeTearDown", "OneTimeTearDownAttribute", + "Fact", "FactAttribute", + "Theory", "TheoryAttribute", + "TestMethod", "TestMethodAttribute", + "DataTestMethod", "DataTestMethodAttribute", + "TestClass", "TestClassAttribute", + "TestInitialize", "TestInitializeAttribute", + "TestCleanup", "TestCleanupAttribute", + "ClassInitialize", "ClassInitializeAttribute", + "ClassCleanup", "ClassCleanupAttribute", + "Benchmark", "BenchmarkAttribute" + }; + + public static List Calculate(Graph.CodeGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + + // Alive because something references it (directly or through a contract). + var referenced = new HashSet(); + + // Alive by assumption only: implements a contract from outside the analyzed code. + var assumedAlive = new HashSet(); + + // Internal contract member -> the members implementing / overriding it, and the reverse. + var implementations = new Dictionary>(); + var contracts = new Dictionary>(); + + CollectEdges(graph, referenced, assumedAlive, implementations, contracts); + PropagateContractUsage(referenced, implementations); + + return Report(graph, referenced, assumedAlive, implementations, contracts); + } + + private static void CollectEdges(Graph.CodeGraph graph, HashSet referenced, HashSet assumedAlive, + Dictionary> implementations, Dictionary> contracts) + { + // Reused across relationships to keep the walk allocation free. + var sourceChain = new HashSet(); + + foreach (var relationship in graph.GetAllRelationships()) + { + var source = graph.TryGetCodeElement(relationship.SourceId); + var target = graph.TryGetCodeElement(relationship.TargetId); + if (source is null || target is null) + { + continue; + } + + if (IsPolymorphicEdge(relationship.Type, source)) + { + RecordPolymorphicEdge(source, target, assumedAlive, implementations, contracts); + continue; + } + + // Containment, Bundled and Handles are not references. Handles (handler -> event) is the + // callback wiring; the registration site itself produces the method group "Uses" edge that + // keeps the handler alive, so nothing is lost by ignoring it here. + if (!relationship.Type.IsDependency()) + { + continue; + } + + MarkReferenced(source, target, referenced, sourceChain); + } + } + + /// + /// A relationship keeps alive every element whose subtree it enters from the outside: walking up + /// from the target, that is everything below the lowest common ancestor with the source. The common + /// ancestor and everything above it contain the source as well, so for them the relationship is an + /// internal one and proves nothing. + /// + private static void MarkReferenced(CodeElement source, CodeElement target, HashSet referenced, + HashSet sourceChain) + { + sourceChain.Clear(); + for (var current = source; current is not null; current = current.Parent) + { + sourceChain.Add(current.Id); + } + + for (var current = target; current is not null; current = current.Parent) + { + if (sourceChain.Contains(current.Id)) + { + break; + } + + referenced.Add(current.Id); + } + } + + /// + /// Marks an element alive whose caller is not part of the graph (a contract call reaching all + /// implementations). Without a source there is no common ancestor, so the whole chain is alive. + /// + private static void MarkReferencedFromOutside(CodeElement element, HashSet referenced) + { + for (var current = element; current is not null; current = current.Parent) + { + referenced.Add(current.Id); + } + } + + /// + /// "Implements" and "Overrides" starting at a member express polymorphism, not use. Starting at a + /// type ("class C : IFoo") the same relationship names the interface in C's declaration and is an + /// ordinary reference - which is why the source, not the target, decides. + /// + private static bool IsPolymorphicEdge(RelationshipType type, CodeElement source) + { + return (type is RelationshipType.Implements or RelationshipType.Overrides) && !source.IsType(); + } + + private static void RecordPolymorphicEdge(CodeElement source, CodeElement target, HashSet assumedAlive, + Dictionary> implementations, Dictionary> contracts) + { + if (target.IsExternal || target.IsType()) + { + // Either a framework contract, or the parser's fallback to the containing type because it could + // not resolve the exact base member (generic base methods). Both mean the caller is invisible, + // so the member is assumed alive. The assumption covers the member and its accessors, but it is + // deliberately not pushed to the containing type - implementing IDisposable is not a use of the + // class. + foreach (var element in source.GetSubtreeIncludingSelf()) + { + assumedAlive.Add(element.Id); + } + + return; + } + + Add(implementations, target.Id, source); + Add(contracts, source.Id, target); + } + + /// + /// Pushes liveness from a used contract member to its implementations: calling IFoo.Bar calls every + /// implementation of Bar, so the implementations and the types holding them are alive. Transitive, + /// because an override can itself be overridden. + /// + private static void PropagateContractUsage(HashSet referenced, + Dictionary> implementations) + { + var queue = new Queue(implementations.Keys.Where(referenced.Contains)); + var enqueued = new HashSet(queue); + + while (queue.Count > 0) + { + foreach (var implementation in implementations[queue.Dequeue()]) + { + MarkReferencedFromOutside(implementation, referenced); + + if (implementations.ContainsKey(implementation.Id) && enqueued.Add(implementation.Id)) + { + queue.Enqueue(implementation.Id); + } + } + } + } + + private static List Report(Graph.CodeGraph graph, HashSet referenced, + HashSet assumedAlive, Dictionary> implementations, + Dictionary> contracts) + { + var findings = new List(); + + foreach (var element in graph.Nodes.Values) + { + if (!IsCandidate(element) || IsAlive(element)) + { + continue; + } + + // Roll-up: a dead element inside a dead element is reported as part of it. Namespaces and + // assemblies are no candidates, so an element directly below them is always the topmost one. + var parent = element.Parent; + if (parent is not null && IsCandidate(parent) && !IsAlive(parent)) + { + continue; + } + + findings.Add(CreateFinding(element, implementations, contracts)); + } + + return findings.OrderBy(f => f.Element.FullName, StringComparer.Ordinal).ToList(); + + bool IsAlive(CodeElement element) + { + return referenced.Contains(element.Id) || assumedAlive.Contains(element.Id); + } + } + + private static DeadCodeFinding CreateFinding(CodeElement element, + Dictionary> implementations, Dictionary> contracts) + { + var hints = DeadCodeHint.None; + var attributes = new SortedSet(StringComparer.Ordinal); + + // The hints are collected over the whole subtree: what is reported is a dead class, but the + // evidence that it may still be alive usually sits on its members ([Test] methods, Main, ...). + foreach (var member in element.GetSubtreeIncludingSelf()) + { + if (IsEntryPoint(member)) + { + hints |= DeadCodeHint.EntryPoint; + } + + foreach (var attribute in member.Attributes) + { + if (TestAttributes.Contains(attribute)) + { + hints |= DeadCodeHint.TestCode; + } + else + { + hints |= DeadCodeHint.Attributed; + } + + attributes.Add(attribute); + } + } + + var related = new List(); + + // Reported although it implements an internal contract means the contract is dead as well - + // otherwise the propagation would have marked this element alive. + if (contracts.TryGetValue(element.Id, out var implemented)) + { + hints |= DeadCodeHint.ImplementsDeadContract; + related.AddRange(implemented); + } + + if (implementations.TryGetValue(element.Id, out var implementors)) + { + hints |= DeadCodeHint.ContractNeverCalled; + related.AddRange(implementors); + } + + return new DeadCodeFinding(element) + { + Hints = hints, + Attributes = attributes.ToList(), + RelatedMembers = related + }; + } + + /// + /// Containers never carry relationships of their own, so they would all look dead. External + /// elements are out of scope - we see neither their callers nor their bodies. + /// + private static bool IsCandidate(CodeElement element) + { + return !element.IsExternal && + element.ElementType is not (CodeElementType.Assembly or CodeElementType.Namespace); + } + + /// + /// Started from outside the analyzed code: the program entry point, and the synthetic + /// "GlobalStatements" class the parser creates per assembly for top-level statements. + /// + private static bool IsEntryPoint(CodeElement element) + { + if (element is { ElementType: CodeElementType.Method, Name: "Main" }) + { + return true; + } + + return element is { ElementType: CodeElementType.Class, Name: "GlobalStatements" } && + (element.Parent?.ElementType == CodeElementType.Assembly || + element.Parent is { ElementType: CodeElementType.Namespace, Name: CodeElement.GlobalNamespaceName }); + } + + private static void Add(Dictionary> map, string key, CodeElement value) + { + if (!map.TryGetValue(key, out var list)) + { + list = []; + map[key] = list; + } + + list.Add(value); + } +} diff --git a/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeFinding.cs b/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeFinding.cs new file mode 100644 index 00000000..b4422227 --- /dev/null +++ b/CSharpCodeAnalyst.CodeGraph/Algorithms/DeadCode/DeadCodeFinding.cs @@ -0,0 +1,57 @@ +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; + +/// +/// Reasons why a reported element may nevertheless be alive. The analysis works on the relationships +/// the parser could see, so everything reached through XAML, reflection, dependency injection or +/// serialization looks unreferenced. Rather than silently dropping such elements, they are reported +/// with the hint that explains why they are suspicious - the caller decides what to do with them. +/// +[Flags] +public enum DeadCodeHint +{ + None = 0, + + /// Called from outside the analyzed code by definition (program entry point). + EntryPoint = 1, + + /// The element or something in its subtree carries a known test-framework attribute. + TestCode = 2, + + /// Carries attributes. Attributes often mean an external framework drives the element. + Attributed = 4, + + /// + /// A contract member (interface or base member) that is implemented but never called through the + /// contract - the abstraction itself is unused. + /// + ContractNeverCalled = 8, + + /// + /// Implements or overrides an internal contract member that is itself dead, so it can only be + /// removed together with that contract. + /// + ImplementsDeadContract = 16 +} + +/// +/// One reported element: the topmost element of a dead subtree, plus what we know about it. +/// +public sealed class DeadCodeFinding(CodeElement element) +{ + /// The unreferenced element. Everything below it is dead too and is not reported separately. + public CodeElement Element { get; } = element; + + public DeadCodeHint Hints { get; init; } + + /// Distinct attribute names found on the element and its subtree. + public IReadOnlyList Attributes { get; init; } = []; + + /// + /// The polymorphically related members: the internal contract members this element implements + /// () and the implementations that die with it + /// (). + /// + public IReadOnlyList RelatedMembers { get; init; } = []; +} diff --git a/CSharpCodeAnalyst/Features/Analyzers/AnalyzerManager.cs b/CSharpCodeAnalyst/Features/Analyzers/AnalyzerManager.cs index 29186599..c63837f1 100644 --- a/CSharpCodeAnalyst/Features/Analyzers/AnalyzerManager.cs +++ b/CSharpCodeAnalyst/Features/Analyzers/AnalyzerManager.cs @@ -5,6 +5,7 @@ using CSharpCodeAnalyst.Shared.Contracts; using CSharpCodeAnalyst.Shared.Notifications; using ArchitecturalRules = CSharpCodeAnalyst.Analyzers.ArchitecturalRules; +using DeadCode = CSharpCodeAnalyst.Analyzers.DeadCode; using MethodComplexity = CSharpCodeAnalyst.Analyzers.MethodComplexity; using SystemMetrics = CSharpCodeAnalyst.Analyzers.SystemMetrics; using TypeCohesion = CSharpCodeAnalyst.Analyzers.TypeCohesion; @@ -94,6 +95,10 @@ public void LoadAnalyzers(IPublisher messaging, IUserNotification userNotificati analyzer.DataChanged += (_, _) => RaiseAnalyzerDataChanged(); _analyzers.Add(analyzer.Id, analyzer); + analyzer = new DeadCode.Analyzer(messaging, userNotification); + analyzer.DataChanged += (_, _) => RaiseAnalyzerDataChanged(); + _analyzers.Add(analyzer.Id, analyzer); + } diff --git a/Documentation/dead-code.md b/Documentation/dead-code.md new file mode 100644 index 00000000..4ce45cca --- /dev/null +++ b/Documentation/dead-code.md @@ -0,0 +1,146 @@ +# Dead Code + +[TOC] + +This guide explains the **Dead Code** analysis: what it reports, what it deliberately does not report, and +how much you can trust the result. + +Available via *Analyzers → Dead Code*. The result is a sortable table: + +| Column | Meaning | +| ------------------- | ---------------------------------------------------------------------------------------- | +| Element | The fully qualified name of the unreferenced element. | +| Kind | Class, Interface, Method, Field, Property, ... — the kind of element. | +| Might still be used | Why the element could be alive anyway. **Empty means nothing speaks against deleting it.** | + +Sort by the hint column to get the clean cases together at the top, and use *Jump to code* or *Copy to +explorer graph* from the context menu to check a finding. + +## The rule + +An element is dead when **no relationship enters its subtree from the outside**. + +The subtree is the point. Two things follow from it, and both match how you would judge the code by hand: + +- A class is alive when one of its members is used, even if nothing ever names the class itself. Somebody + calls `Service.Run()`, so `Service` is not dead code — you cannot delete it. +- A class cannot keep itself alive. If its methods only call each other, every one of those calls stays + inside the subtree and proves nothing. The whole class is dead. + +Because a dead element implies a dead subtree, **only the topmost dead element is reported**. If a class is +dead you get one row for the class, not one row per method. If the class is alive but three of its methods +are unused, you get those three rows. + +Namespaces and assemblies are never reported: nothing ever references them in the graph, so they would all +look dead. Code from outside the solution (frameworks, NuGet packages) is out of scope — we see neither its +callers nor its body. + +### Which relationships count as a reference + +`Calls`, `Creates`, `Uses`, `Inherits`, `Invokes`, `UsesAttribute`, and `Implements` between two *types* +(`class C : IFoo` names `IFoo` in C's declaration). + +`Containment` is the parent/child hierarchy, not a use. `Bundled` is an artificial edge the graph view +creates. `Handles` points from the handler to the event and is the callback wiring, not a dependency — but +nothing is lost: registering `x.Click += OnClick` also produces a method-group `Uses` edge that keeps +`OnClick` alive. + +## Interfaces, overrides and abstract members + +`Implements` and `Overrides` between two *members* point from the implementation to the contract. Taken +literally that gives two wrong answers: an implementation never has an incoming reference and would always +look dead, while a contract member always looks used just because somebody implements it. + +So those edges are not counted as references. Instead **liveness is propagated the other way**: + +- A contract member that *is* called keeps every implementation alive — and the types holding them. + Calling `IService.Run()` is a call to `Service.Run()`, even if nothing else ever mentions `Service`. +- A contract member that is *never* called dies together with its implementations. You get one row for the + contract (`Implemented but never called: ...`) and one for each implementation (`Implements unused + contract: ...`). That pair is one of the more valuable findings: an abstraction nobody uses. + +Contracts from **outside** the solution are the exception. We cannot see who calls `IDisposable.Dispose` or +`object.ToString`, so an implementation of them is assumed to be alive and is not reported. That assumption +deliberately stops at the member: **implementing `IDisposable` is not a use of the class.** A class whose +only remaining trace is a `Dispose` method is still reported as dead. + +> **This only works when the graph contains the edge.** With *Include External Code* switched off — the +> default — the parser records no `Implements` / `Overrides` relationship at all for a contract that lives +> outside the solution, because there is no element to point at. So `ToString`, `GetHashCode`, +> `ICommand.Execute`, a `SyntaxWalker.Visit...` override and friends **are** reported as dead, without a +> hint. Recognizing them would require the parser to remember the fact; see the limitations below. + +## The hints + +The analysis can only see what the parser saw. Everything reached through XAML, reflection, dependency +injection, serialization or a test runner therefore looks unreferenced. Those elements are not silently +dropped — they are reported with a hint, and you decide: + +| Hint | Meaning | +| --------------------------------- | ---------------------------------------------------------------------------------- | +| `Entry point` | `Main`, or the synthetic `GlobalStatements` element for top-level statements. | +| `Test code` | The element or something below it carries a known test-framework attribute. | +| `Attributes: ...` | The element carries attributes — often the sign that a framework drives it. | +| `Implemented but never called: ...` | A contract member that is implemented but never called through the contract. | +| `Implements unused contract: ...` | Implements or overrides an internal contract member that is itself dead. | + +The hints are collected over the whole subtree, because the evidence usually sits below what is reported: +a test fixture is reported as a dead *class*, but the `[Test]` attributes are on its methods. + +## What XAML the analysis does see + +Half of XAML is compiled into C# and is therefore fully visible; the other half is not, and the split is +sharp. + +The markup compiler writes a partial class per XAML file (`obj/.../MyView.g.cs`) and that file **is** part of +the compilation. It contains a field per `x:Name`d element and a `Connect` method that wires the event +handlers: + +```csharp +this.CodeTree.ContextMenuOpening += new ContextMenuEventHandler(this.TreeView_ContextMenuOpening); +``` + +That is ordinary C#, so **event handlers declared in XAML and `x:Name`d controls are found** like any other +reference. + +Everything declarative is compiled into **BAML** instead — a binary resource that is resolved by reflection +at runtime. No C# is generated for it, so there is no compile-time reference to see: + +| In XAML | Visible? | +| ------------------------------------ | -------- | +| `Click="Button_Click"` | yes, via `Connect` | +| `x:Name="CodeTree"` | yes, generated field | +| `{Binding SaveCommand}` | no | +| `{x:Static resx:Strings.Header}` | no | +| `{StaticResource myConverter}` | no | +| `{x:Type local:Foo}` | no | +| `` without `x:Name` | no | + +In this repository the app project alone contains 217 `{x:Static}` usages, and none of them appears in any +generated file. That single category is the largest block of false positives. + +## Limitations + +Read these before deleting anything. + +- **Declarative XAML references.** Not all of XAML is invisible — see below. What is invisible is + everything declarative: `{Binding}`, `{x:Static}`, `{StaticResource}`, `{x:Type}` and the instantiation of + a control that has no `x:Name`. Running the analysis on this repository itself, roughly a quarter of all + findings were resource designer properties referenced from XAML via `{x:Static}`. +- **Reflection, DI and serialization** are invisible for the same reason: the reference only exists at + runtime. +- **Overrides of framework members are not recognized.** As described above, the graph carries no edge for + them unless *Include External Code* is on — and even then the parser records the edge as a plain `Uses` + relationship, which is indistinguishable from an ordinary use. Recognizing these would mean giving the + parser a way to mark "this member implements something external" on the element itself. +- **Public API.** Accessibility is not part of the code graph, so a library whose public API is consumed by + a different solution will report most of that API as dead. +- **Only the analyzed scope.** The analysis is only as complete as the loaded graph. If the solution was + parsed with project exclusions, or the graph came from an import, the callers may simply be missing. +- **No cascade.** This is the direct variant: an element counts as alive as soon as *anything* references + it — even something that is itself dead. So a dead class keeps the interface it implements and the + helpers it calls alive. Delete the reported elements and run the analysis again to peel off the next + layer. +- **Dead cycles are not found.** Two classes that only use each other and nothing else each have an + incoming reference, so neither is reported. Finding those requires reachability from an explicit set of + entry points. diff --git a/README.md b/README.md index 2a9481aa..e1a988ca 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,23 @@ All metrics are accessible via the Analyzer Ribbon, and the results are presente ![](Documentation/Images/metrics-example.png) +## Find dead code + +*Analyzers → Dead Code* lists the elements nothing references any more. The rule works on the subtree, so a +class stays alive when one of its methods is used from the outside, and a class whose methods only call each +other is still dead. Only the topmost element of a dead subtree is listed. + +Calls through an interface count for the implementation behind it, so an implementation is not reported just +because it is only reached polymorphically — and a contract that nobody ever calls is reported together with +its implementations. + +References the parser cannot see are not dropped silently: those rows carry a hint in the last column. An +empty hint means nothing speaks against deleting the element. Note that XAML is only half visible — event +handlers and `x:Name`d controls are compiled into C# and are found, while `{Binding}`, `{x:Static}` and +`{StaticResource}` end up in BAML and are resolved by reflection at runtime. + +Details and limitations: [Dead Code](Documentation/dead-code.md) + ## Other languages The tool is built for C# (has its own Roslyn-based parser), but you can also import Java, C++, Python and Dart via external tools. diff --git a/Tests/Helper/TestCodeGraph.cs b/Tests/Helper/TestCodeGraph.cs index b12d37e3..4a588ef0 100644 --- a/Tests/Helper/TestCodeGraph.cs +++ b/Tests/Helper/TestCodeGraph.cs @@ -60,6 +60,14 @@ public CodeElement CreateInterface(string id, CodeElement? parent = null) return element; } + /// An interface from outside the solution (framework contract like IDisposable). + public CodeElement CreateExternalInterface(string id, CodeElement? parent = null) + { + var element = new CodeElement(id, CodeElementType.Interface, id, id, parent) { IsExternal = true }; + Link(parent, element); + return element; + } + public CodeElement CreateDelegate(string id, CodeElement? parent = null) { var element = new CodeElement(id, CodeElementType.Delegate, id, id, parent); @@ -88,6 +96,14 @@ public CodeElement CreateMethod(string id, CodeElement? parent = null) return element; } + /// A method from outside the solution (member of a framework type). + public CodeElement CreateExternalMethod(string id, CodeElement? parent = null) + { + var element = new CodeElement(id, CodeElementType.Method, id, id, parent) { IsExternal = true }; + Link(parent, element); + return element; + } + public CodeElement CreatePropertyAccessor(string id, CodeElement? parent = null) { var element = new CodeElement(id, CodeElementType.PropertyAccessor, id, id, parent); diff --git a/Tests/UnitTests/DeadCode/DeadCodeAnalysisTests.cs b/Tests/UnitTests/DeadCode/DeadCodeAnalysisTests.cs new file mode 100644 index 00000000..a40a3971 --- /dev/null +++ b/Tests/UnitTests/DeadCode/DeadCodeAnalysisTests.cs @@ -0,0 +1,263 @@ +using CodeParserTests.Helper; +using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CodeParserTests.UnitTests.DeadCode; + +[TestFixture] +public class DeadCodeAnalysisTests +{ + [SetUp] + public void SetUp() + { + _graph = new TestCodeGraph(); + } + + private TestCodeGraph _graph = null!; + + private void Rel(CodeElement source, CodeElement target, RelationshipType type) + { + source.Relationships.Add(new Relationship(source.Id, target.Id, type)); + } + + private string[] Reported() + { + return DeadCodeAnalysis.Calculate(_graph).Select(f => f.Element.FullName).ToArray(); + } + + private DeadCodeFinding FindingFor(CodeElement element) + { + return DeadCodeAnalysis.Calculate(_graph).Single(f => f.Element.Id == element.Id); + } + + [Test] + public void Calculate_EmptyGraph_NoFindings() + { + Assert.That(DeadCodeAnalysis.Calculate(_graph), Is.Empty); + } + + [Test] + public void Calculate_UnreferencedClass_Reported() + { + _graph.CreateClass("A"); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "A" })); + } + + [Test] + public void Calculate_ClassUsedByAnotherClass_NotReported() + { + // B uses A, so only B itself has no incoming reference. + var a = _graph.CreateClass("A"); + var b = _graph.CreateClass("B"); + Rel(b, a, RelationshipType.Uses); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "B" })); + } + + [Test] + public void Calculate_MethodCalledFromOutside_KeepsWholeClassAlive() + { + // Nothing names A, but B.M calls A.M -> the reference enters A's subtree from the outside. + var a = _graph.CreateClass("A"); + var am = _graph.CreateMethod("A.M", a); + var b = _graph.CreateClass("B"); + var bm = _graph.CreateMethod("B.M", b); + Rel(bm, am, RelationshipType.Calls); + + // B is dead; B.M is inside it and rolled up. + Assert.That(Reported(), Is.EquivalentTo(new[] { "B" })); + } + + [Test] + public void Calculate_ClassWithOnlyInternalCalls_ReportedAsOneFinding() + { + // A.M1 -> A.M2 does not prove anything about A: the reference never leaves the subtree. + var a = _graph.CreateClass("A"); + var m1 = _graph.CreateMethod("A.M1", a); + var m2 = _graph.CreateMethod("A.M2", a); + Rel(m1, m2, RelationshipType.Calls); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "A" })); + } + + [Test] + public void Calculate_UnusedMemberOfLiveClass_Reported() + { + var a = _graph.CreateClass("A"); + var used = _graph.CreateMethod("A.Used", a); + _graph.CreateMethod("A.Unused", a); + var b = _graph.CreateClass("B"); + var bm = _graph.CreateMethod("B.M", b); + Rel(bm, used, RelationshipType.Calls); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "A.Unused", "B" })); + } + + [Test] + public void Calculate_NestedDeadClass_OnlyOutermostReported() + { + var outer = _graph.CreateClass("Outer"); + var inner = _graph.CreateClass("Outer.Inner", outer); + _graph.CreateMethod("Outer.Inner.M", inner); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "Outer" })); + } + + [Test] + public void Calculate_ContainersAndExternalElements_NeverReported() + { + var assembly = _graph.CreateAssembly("Asm"); + var ns = _graph.CreateNamespace("Asm.Ns", assembly); + _graph.CreateClass("Asm.Ns.A", ns); + _graph.CreateExternalClass("Ext"); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "Asm.Ns.A" })); + } + + [Test] + public void Calculate_ContainmentAndHandles_AreNoReferences() + { + // Handles points handler -> event; it is the callback wiring, not a use of the handler. + var publisher = _graph.CreateClass("Publisher"); + var evt = _graph.CreateEvent("Publisher.Changed", publisher); + var subscriber = _graph.CreateClass("Subscriber"); + var handler = _graph.CreateMethod("Subscriber.OnChanged", subscriber); + Rel(handler, evt, RelationshipType.Handles); + Rel(publisher, subscriber, RelationshipType.Containment); + + // The Handles edge enters Publisher's subtree and the Containment edge enters Subscriber's, + // yet neither is a reference - both classes stay dead. + Assert.That(Reported(), Is.EquivalentTo(new[] { "Publisher", "Subscriber" })); + } + + [Test] + public void Calculate_CalledThroughInterface_KeepsImplementationAndItsTypeAlive() + { + var contract = _graph.CreateInterface("IFoo"); + var contractMember = _graph.CreateMethod("IFoo.Bar", contract); + var impl = _graph.CreateClass("C"); + var implMember = _graph.CreateMethod("C.Bar", impl); + Rel(impl, contract, RelationshipType.Implements); + Rel(implMember, contractMember, RelationshipType.Implements); + + var user = _graph.CreateClass("User"); + var userMethod = _graph.CreateMethod("User.M", user); + Rel(userMethod, contractMember, RelationshipType.Calls); + + // Nobody creates C, yet the call through IFoo.Bar reaches C.Bar - so neither is dead. + Assert.That(Reported(), Is.EquivalentTo(new[] { "User" })); + } + + [Test] + public void Calculate_OverrideChain_LivenessPropagatesTransitively() + { + var contract = _graph.CreateInterface("IFoo"); + var contractMember = _graph.CreateMethod("IFoo.Bar", contract); + var middle = _graph.CreateClass("Base"); + var middleMember = _graph.CreateMethod("Base.Bar", middle); + var leaf = _graph.CreateClass("Derived"); + var leafMember = _graph.CreateMethod("Derived.Bar", leaf); + + Rel(middle, contract, RelationshipType.Implements); + Rel(middleMember, contractMember, RelationshipType.Implements); + Rel(leaf, middle, RelationshipType.Inherits); + Rel(leafMember, middleMember, RelationshipType.Overrides); + + var user = _graph.CreateClass("User"); + Rel(user, contractMember, RelationshipType.Calls); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "User" })); + } + + [Test] + public void Calculate_ContractImplementedButNeverCalled_ContractAndImplementationReported() + { + var contract = _graph.CreateInterface("IFoo"); + var contractMember = _graph.CreateMethod("IFoo.Bar", contract); + var impl = _graph.CreateClass("C"); + var implMember = _graph.CreateMethod("C.Bar", impl); + Rel(impl, contract, RelationshipType.Implements); + Rel(implMember, contractMember, RelationshipType.Implements); + + // C is instantiated, so the class itself is alive - but nobody ever calls Bar. + var user = _graph.CreateClass("User"); + Rel(user, impl, RelationshipType.Creates); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "C.Bar", "IFoo.Bar", "User" })); + + var contractFinding = FindingFor(contractMember); + Assert.That(contractFinding.Hints.HasFlag(DeadCodeHint.ContractNeverCalled), Is.True); + Assert.That(contractFinding.RelatedMembers.Select(m => m.FullName), Is.EquivalentTo(new[] { "C.Bar" })); + + var implFinding = FindingFor(implMember); + Assert.That(implFinding.Hints.HasFlag(DeadCodeHint.ImplementsDeadContract), Is.True); + Assert.That(implFinding.RelatedMembers.Select(m => m.FullName), Is.EquivalentTo(new[] { "IFoo.Bar" })); + } + + [Test] + public void Calculate_ImplementsExternalContract_MemberAliveButClassStillDead() + { + // class C : IDisposable { public void Dispose() {} } - Dispose is called by code we cannot see, + // but implementing IDisposable is no use of C itself. + var external = _graph.CreateExternalInterface("IDisposable"); + var externalMember = _graph.CreateExternalMethod("IDisposable.Dispose", external); + var impl = _graph.CreateClass("C"); + var implMember = _graph.CreateMethod("C.Dispose", impl); + Rel(impl, external, RelationshipType.Implements); + Rel(implMember, externalMember, RelationshipType.Implements); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "C" })); + } + + [Test] + public void Calculate_OverridesUnresolvedBaseMember_MemberAssumedAliveButNotItsType() + { + // The parser falls back to the containing type when it cannot resolve the exact base member + // (generic base methods). We cannot tell who calls it, so the member is assumed alive. + var baseClass = _graph.CreateClass("Base"); + var derived = _graph.CreateClass("Derived"); + var member = _graph.CreateMethod("Derived.M", derived); + Rel(derived, baseClass, RelationshipType.Inherits); + Rel(member, baseClass, RelationshipType.Overrides); + + var user = _graph.CreateClass("User"); + Rel(user, derived, RelationshipType.Creates); + + Assert.That(Reported(), Is.EquivalentTo(new[] { "User" })); + } + + [Test] + public void Calculate_EntryPointAndTestCode_ReportedWithHint() + { + var program = _graph.CreateClass("Program"); + _graph.CreateMethod("Main", program); + + var fixture = _graph.CreateClass("MyTests"); + var testMethod = _graph.CreateMethod("MyTests.ShouldWork", fixture); + testMethod.Attributes.Add("TestAttribute"); + + var service = _graph.CreateClass("Service"); + service.Attributes.Add("ObsoleteAttribute"); + + Assert.That(FindingFor(program).Hints, Is.EqualTo(DeadCodeHint.EntryPoint)); + Assert.That(FindingFor(fixture).Hints, Is.EqualTo(DeadCodeHint.TestCode)); + Assert.That(FindingFor(service).Hints, Is.EqualTo(DeadCodeHint.Attributed)); + Assert.That(FindingFor(service).Attributes, Is.EquivalentTo(new[] { "ObsoleteAttribute" })); + } + + [Test] + public void Calculate_UnusedPropertyAccessor_Reported() + { + var a = _graph.CreateClass("A"); + var property = _graph.CreateProperty("A.Value", a); + var getter = _graph.CreatePropertyAccessor("A.get_Value", property); + _graph.CreatePropertyAccessor("A.set_Value", property); + + var user = _graph.CreateClass("User"); + Rel(user, getter, RelationshipType.Calls); + + // The setter is never used, the getter and everything above it is. + Assert.That(Reported(), Is.EquivalentTo(new[] { "A.set_Value", "User" })); + } +} diff --git a/Tests/UnitTests/DeadCode/DeadCodeParseTests.cs b/Tests/UnitTests/DeadCode/DeadCodeParseTests.cs new file mode 100644 index 00000000..f7c57547 --- /dev/null +++ b/Tests/UnitTests/DeadCode/DeadCodeParseTests.cs @@ -0,0 +1,100 @@ +using CodeParserTests.UnitTests.Parser; +using CSharpCodeAnalyst.CodeGraph.Algorithms.DeadCode; + +namespace CodeParserTests.UnitTests.DeadCode; + +/// +/// End-to-end check of the dead code analysis against a real parse result: the synthetic graph tests +/// pin the rules, this fixture proves the rules match what the parser actually produces. +/// +[TestFixture] +public class DeadCodeParseTests : InMemoryParseTestBase +{ + protected override string Code => """ + namespace Demo; + + public interface IService + { + void Run(); + void NeverCalled(); + } + + public class Service : IService + { + private readonly int _used = 1; + private readonly int _unused = 2; + + public void Run() { Helper.Help(_used); } + public void NeverCalled() { } + private void PrivateUnused() { } + } + + public static class Helper + { + public static void Help(int x) { } + public static void UnusedHelp() { } + } + + public class DeadClass + { + public void A() { B(); } + private void B() { } + } + + public class Program + { + public static void Main() + { + IService service = new Service(); + service.Run(); + } + } + """; + + private string[] Reported() + { + return DeadCodeAnalysis.Calculate(Graph).Select(f => PathOf(f.Element)).ToArray(); + } + + [Test] + public void Calculate_ReportsExactlyTheUnreferencedElements() + { + // Service and Helper are reached from Main, IService.Run through the interface call. + // DeadClass only calls itself, Program is only the entry point holder. + Assert.That(Reported(), Is.EquivalentTo(new[] + { + "DeadClass", + "Program", + "Helper.UnusedHelp", + "IService.NeverCalled", + "Service.NeverCalled", + "Service.PrivateUnused", + "Service._unused" + })); + } + + [Test] + public void Calculate_MainHolder_CarriesEntryPointHint() + { + var program = DeadCodeAnalysis.Calculate(Graph).Single(f => PathOf(f.Element) == "Program"); + + Assert.That(program.Hints.HasFlag(DeadCodeHint.EntryPoint), Is.True); + } + + [Test] + public void Calculate_UncalledContract_LinksToItsImplementation() + { + var findings = DeadCodeAnalysis.Calculate(Graph); + var contract = findings.Single(f => PathOf(f.Element) == "IService.NeverCalled"); + var implementation = findings.Single(f => PathOf(f.Element) == "Service.NeverCalled"); + + Assert.Multiple(() => + { + Assert.That(contract.Hints.HasFlag(DeadCodeHint.ContractNeverCalled), Is.True); + Assert.That(contract.RelatedMembers.Select(PathOf), Is.EquivalentTo(new[] { "Service.NeverCalled" })); + + Assert.That(implementation.Hints.HasFlag(DeadCodeHint.ImplementsDeadContract), Is.True); + Assert.That(implementation.RelatedMembers.Select(PathOf), Is.EquivalentTo(new[] { "IService.NeverCalled" })); + }); + } +} From 84fc130590e62f5504aa756c7c7b34a287a6f431 Mon Sep 17 00:00:00 2001 From: ATrefzer <36333177+ATrefzer@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:30:41 +0200 Subject: [PATCH 02/10] Parse xaml files to get additional dependencies --- .../Presentation/DeadCodeRowViewModel.cs | 6 +- .../Presentation/DeadCodeViewModel.cs | 4 +- .../Resources/Strings.Designer.cs | 2 +- .../Resources/Strings.resx | 2 +- .../Graph/RelationshipAttribute.cs | 9 +- .../Parser/Config/ParserConfig.cs | 12 +- CSharpCodeAnalyst.CodeParser/Parser/Parser.cs | 40 +++ .../Xaml/XamlGraphLinker.cs | 237 ++++++++++++++++++ .../Xaml/XamlReferenceExtractor.cs | 196 +++++++++++++++ .../Roslyn/corrections-and-updates.md | 38 +++ Documentation/dead-code.md | 118 +++++---- README.md | 20 +- Tests/UnitTests/Xaml/XamlGraphLinkerTests.cs | 220 ++++++++++++++++ .../Xaml/XamlReferenceExtractorTests.cs | 233 +++++++++++++++++ 14 files changed, 1074 insertions(+), 63 deletions(-) create mode 100644 CSharpCodeAnalyst.CodeParser/Xaml/XamlGraphLinker.cs create mode 100644 CSharpCodeAnalyst.CodeParser/Xaml/XamlReferenceExtractor.cs create mode 100644 Tests/UnitTests/Xaml/XamlGraphLinkerTests.cs create mode 100644 Tests/UnitTests/Xaml/XamlReferenceExtractorTests.cs diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs index 28ff0eae..677a727d 100644 --- a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeRowViewModel.cs @@ -24,7 +24,11 @@ internal DeadCodeRowViewModel(DeadCodeFinding finding) public string Name { get; } public string Kind { get; } - /// Why the element might be alive despite having no visible reference. Empty means: no doubts. + /// + /// Two kinds of note, joined into one cell: why the element might be alive despite having no + /// visible reference (entry point, test code, attributes), and - for a contract finding - what + /// dies together with it. Empty means neither applies, so nothing speaks against deleting it. + /// public string Hint { get; } private static string FormatHint(DeadCodeFinding finding) diff --git a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs index 9c2cdba6..01c1f2c0 100644 --- a/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs +++ b/CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs @@ -43,7 +43,9 @@ public override IEnumerable GetColumns() }, new() { - // Empty means nothing speaks against deleting it - sorting brings those rows together. + // Carries both the doubts (entry point, test code, attributes) and the explanation of a + // contract finding. Empty means nothing speaks against deleting the element, and sorting + // brings those rows together. Type = ColumnType.Text, Header = Strings.Column_DeadCode_Hint, PropertyName = nameof(DeadCodeRowViewModel.Hint) diff --git a/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs b/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs index ce4d4497..1c699922 100644 --- a/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs +++ b/CSharpCodeAnalyst.Analyzers/Resources/Strings.Designer.cs @@ -294,7 +294,7 @@ public static string Column_DeadCode_Element { } /// - /// Looks up a localized string similar to Might still be used. + /// Looks up a localized string similar to Notes. /// public static string Column_DeadCode_Hint { get { diff --git a/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx b/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx index 129e5770..83e9a24d 100644 --- a/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx +++ b/CSharpCodeAnalyst.Analyzers/Resources/Strings.resx @@ -220,7 +220,7 @@ Kind - Might still be used + Notes Entry point diff --git a/CSharpCodeAnalyst.CodeGraph/Graph/RelationshipAttribute.cs b/CSharpCodeAnalyst.CodeGraph/Graph/RelationshipAttribute.cs index e3c77592..4e0c500f 100644 --- a/CSharpCodeAnalyst.CodeGraph/Graph/RelationshipAttribute.cs +++ b/CSharpCodeAnalyst.CodeGraph/Graph/RelationshipAttribute.cs @@ -17,7 +17,14 @@ public enum RelationshipAttribute : uint IsExtensionMethodCall = 16, IsMethodGroup = 32, EventRegistration = 64, - EventUnregistration = 128 + EventUnregistration = 128, + + /// + /// The relationship was read out of a XAML file, not out of C#. Declarative XAML (element tags, + /// {x:Static}, {x:Type}) is compiled into BAML and resolved by reflection at runtime, so the + /// markup compiler generates no C# for it and Roslyn cannot see it. + /// + IsXamlReference = 256 } public static class RelationshipAttributeExtensions diff --git a/CSharpCodeAnalyst.CodeParser/Parser/Config/ParserConfig.cs b/CSharpCodeAnalyst.CodeParser/Parser/Config/ParserConfig.cs index 6cb505f5..647e301f 100644 --- a/CSharpCodeAnalyst.CodeParser/Parser/Config/ParserConfig.cs +++ b/CSharpCodeAnalyst.CodeParser/Parser/Config/ParserConfig.cs @@ -5,12 +5,14 @@ public class ParserConfig private readonly ProjectExclusionRegExCollection _projectExclusionFilters; public ParserConfig(ProjectExclusionRegExCollection projectExclusionFilters, bool includeExternals, - bool includeGeneratedCode = false, bool splitPropertyAccessors = false) + bool includeGeneratedCode = false, bool splitPropertyAccessors = false, + bool includeXamlReferences = true) { _projectExclusionFilters = projectExclusionFilters; IncludeExternals = includeExternals; IncludeGeneratedCode = includeGeneratedCode; SplitPropertyAccessors = splitPropertyAccessors; + IncludeXamlReferences = includeXamlReferences; } public bool IncludeExternals { get; } @@ -31,6 +33,14 @@ public ParserConfig(ProjectExclusionRegExCollection projectExclusionFilters, boo /// public bool SplitPropertyAccessors { get; } + /// + /// When enabled, the XAML files next to the analyzed projects are scanned for the references the + /// markup compiler does not turn into C# (element tags, {x:Static}, {x:Type}) and + /// those become relationships in the graph. Without it a control that is only instantiated from + /// XAML looks unreferenced. + /// + public bool IncludeXamlReferences { get; } + public bool IsProjectIncluded(string projectName) { diff --git a/CSharpCodeAnalyst.CodeParser/Parser/Parser.cs b/CSharpCodeAnalyst.CodeParser/Parser/Parser.cs index 54a31abd..7a510de9 100644 --- a/CSharpCodeAnalyst.CodeParser/Parser/Parser.cs +++ b/CSharpCodeAnalyst.CodeParser/Parser/Parser.cs @@ -3,6 +3,7 @@ using CSharpCodeAnalyst.CodeGraph.Graph; using CSharpCodeAnalyst.CodeGraph.Metrics; using CSharpCodeAnalyst.CodeParser.Parser.Config; +using CSharpCodeAnalyst.CodeParser.Xaml; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.MSBuild; @@ -201,6 +202,13 @@ private async Task ParseSolutionInternal(Solution solution) sw.Stop(); Trace.TraceInformation("Analyzing relationships: " + sw.Elapsed); + // Third pass: the XAML references Roslyn cannot see. Runs before the global namespace is inserted + // so the synthetic elements for code-behind-less files are moved along with everything else. + if (config.IncludeXamlReferences) + { + LinkXamlReferences(solution, codeGraph); + } + // Makes the cycle detection easier because I never get to the assembly as shared ancestor // for a nested relationships. InsertGlobalNamespaceIfUsed(codeGraph); @@ -214,6 +222,38 @@ private async Task ParseSolutionInternal(Solution solution) } + /// + /// Adds the references that only exist in XAML. The assembly elements are matched to the Roslyn + /// projects by assembly name, and each project contributes the XAML files below its own directory - + /// MSBuildWorkspace does not expose the "Page" items, so the directory is the practical source. + /// + private void LinkXamlReferences(Solution solution, CodeGraph.Graph.CodeGraph codeGraph) + { + progress?.Report("Reading XAML references ..."); + + var assembliesByName = codeGraph.GetRoots() + .Where(root => root.ElementType == CodeElementType.Assembly) + .ToDictionary(root => root.Name, root => root); + + var projects = new List(); + foreach (var project in solution.Projects) + { + var directory = Path.GetDirectoryName(project.FilePath); + if (directory is null || !config.IsProjectIncluded(project.Name) || + !assembliesByName.TryGetValue(project.AssemblyName, out var assembly)) + { + continue; + } + + projects.Add(new XamlProject(assembly, directory)); + } + + var sw = Stopwatch.StartNew(); + var added = XamlGraphLinker.Link(codeGraph, projects); + sw.Stop(); + Trace.TraceInformation($"Reading XAML references: {sw.Elapsed} ({added} relationships)"); + } + /// /// Computes per-member source metrics from the symbol map built in phase 1. /// Only method-like symbols with an actual implementation are measured; abstract/extern/ diff --git a/CSharpCodeAnalyst.CodeParser/Xaml/XamlGraphLinker.cs b/CSharpCodeAnalyst.CodeParser/Xaml/XamlGraphLinker.cs new file mode 100644 index 00000000..00a1feb1 --- /dev/null +++ b/CSharpCodeAnalyst.CodeParser/Xaml/XamlGraphLinker.cs @@ -0,0 +1,237 @@ +using CSharpCodeAnalyst.CodeGraph.Graph; + +namespace CSharpCodeAnalyst.CodeParser.Xaml; + +/// One analyzed project: its assembly element in the graph and the directory to scan for XAML. +public sealed record XamlProject(CodeElement Assembly, string Directory); + +/// +/// Turns the references finds into real relationships in the code +/// graph, so a type that is only ever instantiated from XAML no longer looks unused. +/// +/// The source of such a relationship is the code-behind class named by x:Class. A resource +/// dictionary has no code-behind, so a synthetic class named after the file takes its place - +/// the same device the parser already uses for top-level statements ("GlobalStatements"). Those +/// synthetic elements have no incoming references of their own (nothing resolves the +/// MergedDictionaries URIs), so they show up in a dead code analysis. That is a known and +/// accepted cost; there were 14 of them in this repository against 1050 findings. +/// +/// +/// Resolution is by exact name, never by guessing: the xmlns gives the CLR namespace and optionally +/// the assembly. Without ;assembly= XAML means the assembly the file is compiled into, which +/// is what is tried first; a unique match elsewhere is accepted as a fallback. +/// +/// +public static class XamlGraphLinker +{ + public static int Link(CodeGraph.Graph.CodeGraph graph, IReadOnlyList projects) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(projects); + + var typesByAssembly = BuildTypeLookup(graph); + var added = 0; + + foreach (var project in projects) + { + foreach (var file in EnumerateXamlFiles(project.Directory)) + { + added += LinkFile(graph, project, file, typesByAssembly); + } + } + + return added; + } + + private static int LinkFile(CodeGraph.Graph.CodeGraph graph, XamlProject project, string file, + Dictionary> typesByAssembly) + { + XamlFileReferences references; + try + { + references = XamlReferenceExtractor.Extract(File.ReadAllText(file)); + } + catch (IOException) + { + // An unreadable file must not break the parse run. + return 0; + } + + if (references.References.Count == 0) + { + return 0; + } + + var source = ResolveSource(graph, project, file, references, typesByAssembly); + var added = 0; + + foreach (var reference in references.References) + { + var target = ResolveTarget(project, reference, typesByAssembly); + if (target is null || target.Id == source.Id) + { + continue; + } + + if (AddReference(source, target, file, reference)) + { + added++; + } + } + + return added; + } + + /// + /// Adds the relationship, or merges the location into the existing one. The relationship set is + /// keyed by (source, target, type), so a plain Add would silently drop the new source location. + /// + private static bool AddReference(CodeElement source, CodeElement target, string file, XamlReference reference) + { + var location = new SourceLocation(file, reference.Line, reference.Column); + var existing = source.Relationships.FirstOrDefault( + r => r.TargetId == target.Id && r.Type == RelationshipType.Uses); + + if (existing is not null) + { + if (!existing.SourceLocations.Contains(location)) + { + existing.SourceLocations.Add(location); + } + + existing.SetAttribute(RelationshipAttribute.IsXamlReference); + return false; + } + + var relationship = new Relationship(source.Id, target.Id, RelationshipType.Uses, + RelationshipAttribute.IsXamlReference); + relationship.SourceLocations.Add(location); + source.Relationships.Add(relationship); + return true; + } + + private static CodeElement ResolveSource(CodeGraph.Graph.CodeGraph graph, XamlProject project, string file, + XamlFileReferences references, Dictionary> typesByAssembly) + { + if (references.CodeBehindClass is not null && + typesByAssembly.TryGetValue(project.Assembly.Name, out var types) && + types.TryGetValue(references.CodeBehindClass, out var codeBehind)) + { + return codeBehind; + } + + return GetOrCreateSyntheticElement(graph, project, file); + } + + /// + /// The stand-in for a XAML file that has no code-behind class. Named after the path relative to the + /// project so two files with the same name stay distinguishable. + /// + private static CodeElement GetOrCreateSyntheticElement(CodeGraph.Graph.CodeGraph graph, XamlProject project, + string file) + { + var name = Path.ChangeExtension(Path.GetRelativePath(project.Directory, file), null) + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + + var fullName = project.Assembly.FullName + "." + name; + + var existing = project.Assembly.Children.FirstOrDefault(c => c.FullName == fullName); + if (existing is not null) + { + return existing; + } + + var element = new CodeElement(Guid.NewGuid().ToString(), CodeElementType.Class, name, fullName, + project.Assembly); + element.SourceLocations.Add(new SourceLocation(file, 1, 1)); + + project.Assembly.Children.Add(element); + graph.Nodes[element.Id] = element; + return element; + } + + private static CodeElement? ResolveTarget(XamlProject project, XamlReference reference, + Dictionary> typesByAssembly) + { + var type = ResolveType(project, reference, typesByAssembly); + if (type is null || reference.MemberName is null) + { + return type; + } + + // {x:Static Type.Member} - prefer the member, fall back to the type when it has no element + // (e.g. an enum value or a member the parser did not model). + return type.Children.FirstOrDefault(c => c.Name == reference.MemberName) ?? type; + } + + private static CodeElement? ResolveType(XamlProject project, XamlReference reference, + Dictionary> typesByAssembly) + { + // An explicit ";assembly=" wins; without it XAML means the assembly the file is compiled into. + var assemblyName = reference.AssemblyName ?? project.Assembly.Name; + if (typesByAssembly.TryGetValue(assemblyName, out var types) && + types.TryGetValue(reference.TypeFullName, out var declared)) + { + return declared; + } + + // Fallback: a unique match anywhere. Ambiguous names are dropped rather than guessed. + var matches = typesByAssembly.Values + .Select(candidates => candidates.GetValueOrDefault(reference.TypeFullName)) + .Where(candidate => candidate is not null) + .Take(2) + .ToList(); + + return matches.Count == 1 ? matches[0] : null; + } + + /// + /// Maps assembly name -> CLR full name ("Namespace.Type") -> type element. The assembly node and + /// the synthetic global namespace are not part of a CLR name and are skipped. + /// + private static Dictionary> BuildTypeLookup( + CodeGraph.Graph.CodeGraph graph) + { + var lookup = new Dictionary>(); + + foreach (var element in graph.Nodes.Values) + { + if (!element.IsType() || element.IsExternal) + { + continue; + } + + var path = element.GetPathToRoot(true); + if (path.Count < 2 || path[0].ElementType != CodeElementType.Assembly) + { + continue; + } + + var segments = path.Skip(1) + .Where(p => p.ElementType != CodeElementType.Namespace || + p.Name != CodeElement.GlobalNamespaceName) + .Select(p => p.Name); + + var types = lookup.TryGetValue(path[0].Name, out var existing) ? existing : lookup[path[0].Name] = []; + + // A name collision would mean two types with the same full name in one assembly, which the + // compiler would already have rejected. + types[string.Join(".", segments)] = element; + } + + return lookup; + } + + private static IEnumerable EnumerateXamlFiles(string directory) + { + if (!Directory.Exists(directory)) + { + return []; + } + + return Directory.EnumerateFiles(directory, "*.xaml", SearchOption.AllDirectories) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") && + !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")); + } +} diff --git a/CSharpCodeAnalyst.CodeParser/Xaml/XamlReferenceExtractor.cs b/CSharpCodeAnalyst.CodeParser/Xaml/XamlReferenceExtractor.cs new file mode 100644 index 00000000..181ce03e --- /dev/null +++ b/CSharpCodeAnalyst.CodeParser/Xaml/XamlReferenceExtractor.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace CSharpCodeAnalyst.CodeParser.Xaml; + +/// +/// A single reference to CLR code found in a XAML file. is null when the +/// whole type is referenced (an element tag, {x:Type}), and set for {x:Static}. +/// comes from the ;assembly= part of the xmlns and is null when the +/// xmlns omits it - which means the type lives in the same assembly as the XAML file. +/// +public sealed record XamlReference( + string NamespaceName, + string TypeName, + string? MemberName, + string? AssemblyName, + int Line, + int Column) +{ + public string TypeFullName => $"{NamespaceName}.{TypeName}"; +} + +/// +/// Everything one XAML file contributes: the code-behind class it belongs to (from x:Class, null +/// for a resource dictionary) and the CLR references it makes. +/// +public sealed class XamlFileReferences +{ + public string? CodeBehindClass { get; init; } + public IReadOnlyList References { get; init; } = []; +} + +/// +/// Reads the CLR references out of a XAML file - the ones the markup compiler does *not* turn into C#. +/// +/// The WPF markup compiler generates a partial class per XAML file that contains the event handler +/// wiring and a field per x:Name, so those references are already visible to Roslyn. What +/// never reaches C# is everything declarative: it is compiled into BAML and resolved by reflection +/// at runtime. Three of those constructs carry a fully qualified CLR name and can therefore be +/// resolved exactly, which is what this extractor collects: +/// +/// +/// element tags - <local:MyControl/>, including property element syntax +/// {x:Static local:Texts.Caption} +/// {x:Type local:Foo} +/// +/// +/// {Binding Path} is deliberately NOT collected. Without evaluating the DataContext it is a +/// bare member name, and matching that by name across the whole codebase would suppress far more +/// than it explains. +/// +/// +/// Prefixes are resolved through the XML namespace declarations, so a clr-namespace xmlns is +/// mapped exactly - there is no name guessing anywhere in here. +/// +/// +public static class XamlReferenceExtractor +{ + private const string ClrNamespacePrefix = "clr-namespace:"; + private const string XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml"; + + /// + /// Matches "{prefix:Static target:Type.Member}" and "{prefix:Type target:Type}", also when nested + /// inside another markup extension. Both prefixes are resolved against the element, never assumed. + /// + private static readonly Regex MarkupExtension = new( + @"\{\s*(?\w+)\s*:\s*(?Static|Type)\s+(?\w+)\s*:\s*(?[\w.]+)", + RegexOptions.Compiled); + + public static XamlFileReferences Extract(string xaml) + { + ArgumentNullException.ThrowIfNull(xaml); + + XDocument document; + try + { + document = XDocument.Parse(xaml, LoadOptions.SetLineInfo); + } + catch (XmlException) + { + // A malformed or unsupported file contributes nothing. It must never break the parse run. + return new XamlFileReferences(); + } + + var references = new List(); + + foreach (var element in document.Descendants()) + { + CollectElementTag(element, references); + + foreach (var attribute in element.Attributes()) + { + CollectAttachedProperty(attribute, references); + CollectMarkupExtensions(element, attribute, references); + } + } + + return new XamlFileReferences + { + CodeBehindClass = document.Root?.Attribute(XName.Get("Class", XamlNamespace))?.Value, + References = references + }; + } + + /// + /// The element tag itself: <local:MyControl/>. Property element syntax puts the property + /// behind a dot (<local:MyControl.Items>), so only the part in front of it is the type. + /// + private static void CollectElementTag(XElement element, List references) + { + Add(element.Name.NamespaceName, element.Name.LocalName, element, references); + } + + /// An attached property written as local:MyPanel.Dock="..." references MyPanel. + private static void CollectAttachedProperty(XAttribute attribute, List references) + { + Add(attribute.Name.NamespaceName, attribute.Name.LocalName, attribute, references); + } + + private static void CollectMarkupExtensions(XElement element, XAttribute attribute, + List references) + { + foreach (Match match in MarkupExtension.Matches(attribute.Value)) + { + // "x" is only a convention - verify the prefix really maps to the XAML language namespace. + var xamlNamespace = element.GetNamespaceOfPrefix(match.Groups["xamlPrefix"].Value); + if (xamlNamespace?.NamespaceName != XamlNamespace) + { + continue; + } + + var targetNamespace = element.GetNamespaceOfPrefix(match.Groups["prefix"].Value); + if (targetNamespace is null) + { + continue; + } + + var path = match.Groups["path"].Value; + if (match.Groups["kind"].Value == "Type") + { + Add(targetNamespace.NamespaceName, path, attribute, references); + continue; + } + + // {x:Static Type.Member} - the last segment is the member. + var separator = path.LastIndexOf('.'); + if (separator <= 0 || separator == path.Length - 1) + { + continue; + } + + Add(targetNamespace.NamespaceName, path[..separator], attribute, references, + path[(separator + 1)..]); + } + } + + private static void Add(string namespaceName, string localName, IXmlLineInfo position, + List references, string? memberName = null) + { + if (!namespaceName.StartsWith(ClrNamespacePrefix, StringComparison.Ordinal)) + { + // A framework namespace (presentation, xaml, ...) - nothing of ours is referenced. + return; + } + + // "clr-namespace:Some.Namespace;assembly=Some.Assembly" - the assembly part is optional and + // absent exactly when the type lives in the same assembly as the XAML file. + var declaration = namespaceName[ClrNamespacePrefix.Length..]; + var semicolon = declaration.IndexOf(';'); + var clrNamespace = semicolon < 0 ? declaration : declaration[..semicolon]; + + string? assemblyName = null; + if (semicolon >= 0) + { + const string assemblyKey = "assembly="; + var assemblyPart = declaration[(semicolon + 1)..].Trim(); + if (assemblyPart.StartsWith(assemblyKey, StringComparison.Ordinal)) + { + assemblyName = assemblyPart[assemblyKey.Length..].Trim(); + } + } + + // Property element syntax: names the type in front of the dot. + var dot = localName.IndexOf('.'); + var typeName = dot < 0 ? localName : localName[..dot]; + + if (clrNamespace.Length == 0 || typeName.Length == 0) + { + return; + } + + references.Add(new XamlReference(clrNamespace, typeName, memberName, assemblyName, + position.LineNumber, position.LinePosition)); + } +} diff --git a/Documentation/Roslyn/corrections-and-updates.md b/Documentation/Roslyn/corrections-and-updates.md index 4ed55759..748fad55 100644 --- a/Documentation/Roslyn/corrections-and-updates.md +++ b/Documentation/Roslyn/corrections-and-updates.md @@ -275,3 +275,41 @@ Phase 2 now walks the declarations of **both** parts (`GetDeclaringSyntaxReferencesIncludingPartial`, using `PartialImplementationPart` / `PartialDefinitionPart`), for methods and for partial properties (C# 13) alike. The source metrics measure the implementation part. Partial *events* (C# 14) are not special-cased yet. + +## XAML: the half the markup compiler does not generate + +The WPF markup compiler writes a partial class per XAML file (`obj/.../MyView.g.cs`) and MSBuildWorkspace +runs that pass during its design-time build, so the file is part of the compilation even for a solution +that was never built. It contains the event handler wiring (`IComponentConnector.Connect`, and +`IStyleConnector.Connect` for handlers inside templates) and one field per `x:Name`. Those references are +therefore plain C# and need nothing special. + +Two things are *not* in there, and both were mistaken for dead code before: + +- Everything declarative - element tags, `{x:Static}`, `{x:Type}`, `{Binding}`, `{StaticResource}` - is + compiled into BAML and resolved by reflection at runtime. +- `x:Name` only produces a field in the file's **main name scope**. A `DataTemplate`, `ControlTemplate` or + `Style` is its own name scope and gets no field. `MainWindow.xaml` in this repository has ten `x:Name`s + and nine generated fields; the missing one sits inside a `DataTemplate`. + +So a control can be used three times in XAML, once even with a name, and produce no C# reference at all. + +A third pass (`Xaml/XamlReferenceExtractor` + `Xaml/XamlGraphLinker`, enabled by +`ParserConfig.IncludeXamlReferences`) therefore reads the XAML files next to each project and adds the +references that carry a **fully qualified CLR name**: element tags, `{x:Static}` and `{x:Type}`. Prefixes +are resolved through the `clr-namespace` xmlns declarations, so nothing is matched by guessing. The +relationships are `Uses` and carry `RelationshipAttribute.IsXamlReference`. + +`{Binding Path=...}` is deliberately left out. Without evaluating the DataContext it is a bare member name, +and matching that across the codebase would suppress far more than it explains. + +The source of such a relationship is the code-behind class from `x:Class`. A resource dictionary has none, +so a synthetic class named after the file path takes its place - the same device already used for top-level +statements (`GlobalStatements`). It is created only when the file actually contains a resolvable reference. +Since nothing resolves the `Source` / `StartupUri` URIs of merged dictionaries, those synthetic elements +have no incoming reference and do show up in a dead code analysis; there were six of them in this +repository. + +MSBuildWorkspace note: opening a WPF project runs the markup compile through a temporary `_wpftmp.csproj`, +which can invalidate the incremental build state of the real project - a following `dotnet build` may fail +with `CS2001` for every `.g.cs` until it is rebuilt. diff --git a/Documentation/dead-code.md b/Documentation/dead-code.md index 4ce45cca..0bbcb784 100644 --- a/Documentation/dead-code.md +++ b/Documentation/dead-code.md @@ -7,14 +7,23 @@ how much you can trust the result. Available via *Analyzers → Dead Code*. The result is a sortable table: -| Column | Meaning | -| ------------------- | ---------------------------------------------------------------------------------------- | -| Element | The fully qualified name of the unreferenced element. | -| Kind | Class, Interface, Method, Field, Property, ... — the kind of element. | -| Might still be used | Why the element could be alive anyway. **Empty means nothing speaks against deleting it.** | +| Column | Meaning | +| ------- | ---------------------------------------------------------------------------- | +| Element | The fully qualified name of the unreferenced element. | +| Kind | Class, Interface, Method, Field, Property, ... — the kind of element. | +| Notes | Anything worth knowing about the finding. **Empty means nothing speaks against deleting it.** | -Sort by the hint column to get the clean cases together at the top, and use *Jump to code* or *Copy to -explorer graph* from the context menu to check a finding. +Sort by *Notes* to get the clean cases together, and use *Jump to code* or *Copy to explorer graph* from +the context menu to check a finding. + +The *Notes* column carries two different kinds of remark, which is why it is not called something like +"might still be used": + +- A **doubt** — `Entry point`, `Test code`, `Attributes: ...`. The reference may exist somewhere the parser + cannot see, so the finding needs a second look. +- An **explanation** — `Implemented but never called: ...`, `Implements unused contract: ...`. Those are + the opposite of a doubt: the finding is well understood, and the note tells you what dies together with + it. ## The rule @@ -67,25 +76,33 @@ only remaining trace is a `Dispose` method is still reported as dead. > **This only works when the graph contains the edge.** With *Include External Code* switched off — the > default — the parser records no `Implements` / `Overrides` relationship at all for a contract that lives > outside the solution, because there is no element to point at. So `ToString`, `GetHashCode`, -> `ICommand.Execute`, a `SyntaxWalker.Visit...` override and friends **are** reported as dead, without a -> hint. Recognizing them would require the parser to remember the fact; see the limitations below. +> `ICommand.Execute`, a `SyntaxWalker.Visit...` override and friends **are** reported as dead, with an +> empty *Notes* cell. Recognizing them would require the parser to remember the fact; see the limitations +> below. + +## The notes + +The analysis can only see what the parser saw. Everything reached through reflection, dependency injection, +serialization or a test runner therefore looks unreferenced. Those elements are not silently dropped — they +are reported with a note, and you decide. -## The hints +**Doubts** — the reference may exist where the parser cannot look: -The analysis can only see what the parser saw. Everything reached through XAML, reflection, dependency -injection, serialization or a test runner therefore looks unreferenced. Those elements are not silently -dropped — they are reported with a hint, and you decide: +| Note | Meaning | +| ----------------- | ------------------------------------------------------------------------------ | +| `Entry point` | `Main`, or the synthetic `GlobalStatements` element for top-level statements. | +| `Test code` | The element or something below it carries a known test-framework attribute. | +| `Attributes: ...` | The element carries attributes — often the sign that a framework drives it. Every attribute is listed, including the test ones that already produced `Test code`. | -| Hint | Meaning | -| --------------------------------- | ---------------------------------------------------------------------------------- | -| `Entry point` | `Main`, or the synthetic `GlobalStatements` element for top-level statements. | -| `Test code` | The element or something below it carries a known test-framework attribute. | -| `Attributes: ...` | The element carries attributes — often the sign that a framework drives it. | -| `Implemented but never called: ...` | A contract member that is implemented but never called through the contract. | -| `Implements unused contract: ...` | Implements or overrides an internal contract member that is itself dead. | +**Explanations** — the finding is understood, and the note names what dies with it: -The hints are collected over the whole subtree, because the evidence usually sits below what is reported: -a test fixture is reported as a dead *class*, but the `[Test]` attributes are on its methods. +| Note | Meaning | +| ----------------------------------- | ----------------------------------------------------------------------------- | +| `Implemented but never called: ...` | A contract member that is implemented but never called through the contract. | +| `Implements unused contract: ...` | Implements or overrides an internal contract member that is itself dead. | + +Notes are collected over the whole subtree, because the evidence usually sits below what is reported: a +test fixture is reported as a dead *class*, but the `[Test]` attributes are on its methods. ## What XAML the analysis does see @@ -93,40 +110,49 @@ Half of XAML is compiled into C# and is therefore fully visible; the other half sharp. The markup compiler writes a partial class per XAML file (`obj/.../MyView.g.cs`) and that file **is** part of -the compilation. It contains a field per `x:Name`d element and a `Connect` method that wires the event -handlers: +the compilation — MSBuildWorkspace runs the markup compile pass during its design-time build, so this works +even on a solution that was never built. The generated class contains a field per `x:Name`d element and a +`Connect` method that wires the event handlers: ```csharp this.CodeTree.ContextMenuOpening += new ContextMenuEventHandler(this.TreeView_ContextMenuOpening); ``` -That is ordinary C#, so **event handlers declared in XAML and `x:Name`d controls are found** like any other -reference. - -Everything declarative is compiled into **BAML** instead — a binary resource that is resolved by reflection -at runtime. No C# is generated for it, so there is no compile-time reference to see: - -| In XAML | Visible? | -| ------------------------------------ | -------- | -| `Click="Button_Click"` | yes, via `Connect` | -| `x:Name="CodeTree"` | yes, generated field | -| `{Binding SaveCommand}` | no | -| `{x:Static resx:Strings.Header}` | no | -| `{StaticResource myConverter}` | no | -| `{x:Type local:Foo}` | no | -| `` without `x:Name` | no | - -In this repository the app project alone contains 217 `{x:Static}` usages, and none of them appears in any -generated file. That single category is the largest block of false positives. +That is ordinary C#, so those references are found like any other. Everything declarative is compiled into +**BAML** instead — a binary resource resolved by reflection at runtime, with no C# generated for it. The +parser closes most of that gap by reading the XAML files themselves and adding the references that carry a +fully qualified CLR name (see `ParserConfig.IncludeXamlReferences`). + +| In XAML | Found? | +| ------------------------------------------------------------ | ------ | +| `Click="Button_Click"`, anywhere incl. templates | yes, generated C# | +| `x:Name="CodeTree"` in the file's main name scope | yes, generated field | +| `x:Name` inside `DataTemplate` / `ControlTemplate` / `Style` | no field is generated — but the element tag is read from the XAML | +| `` — the element tag | yes, read from the XAML | +| `{x:Static resx:Strings.Header}`, `{x:Type local:Foo}` | yes, read from the XAML | +| `{Binding SaveCommand}` | **no** | +| `{StaticResource key}` | **no** | +| `Source="Styles/Buttons.xaml"`, `StartupUri` | **no** | + +Prefixes are resolved through the `clr-namespace` xmlns declarations, so nothing is matched by guessing. +`{Binding}` is deliberately left out: without evaluating the DataContext it is a bare member name, and +matching that across the codebase would suppress far more than it explains. + +The name-scope rule is worth knowing even so. `MainWindow.xaml` in this repository has ten `x:Name`s and +the generated file has nine fields — the missing one sits inside a `DataTemplate`. Before the XAML files +were read, that control looked unused despite being used three times. + +A XAML file with no code-behind class (a resource dictionary) is represented by a synthetic class named +after its path, so its references have a source. Nothing resolves the `Source` URIs of merged dictionaries, +so those synthetic elements appear in the result themselves — six of them in this repository. ## Limitations Read these before deleting anything. -- **Declarative XAML references.** Not all of XAML is invisible — see below. What is invisible is - everything declarative: `{Binding}`, `{x:Static}`, `{StaticResource}`, `{x:Type}` and the instantiation of - a control that has no `x:Name`. Running the analysis on this repository itself, roughly a quarter of all - findings were resource designer properties referenced from XAML via `{x:Static}`. +- **The rest of XAML.** Most of it is covered — see the section above for the exact split. What remains + invisible is `{Binding}`, `{StaticResource}` and the `Source` / `StartupUri` URIs of merged dictionaries. + Reading the XAML files removed 187 of 1051 findings on this repository. - **Reflection, DI and serialization** are invisible for the same reason: the reference only exists at runtime. - **Overrides of framework members are not recognized.** As described above, the graph carries no edge for diff --git a/README.md b/README.md index e1a988ca..5f185073 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This desktop app helps you explore, understand, and manage large C# codebases, e - **Incremental Graph Exploration** – Build the exact graph you need to understand a codebase or solve a task. Map out dependencies step by step on demand (e.g., "show all incoming relationships"), and easily filter out unrelated code elements to keep the graph free of visual clutter. - **Sandbox Code Refactoring** – Experiment with structural changes and simulate cycle-breaking strategies safely, without touching your actual source code. - **Architectural Guardrails** – Define custom dependency rules and metric thresholds to actively validate and enforce a clean codebase. +- **Dead Code Detection** – Find the types and members nothing references any more, with the cases that only look unused flagged instead of hidden. - **Multi-Format Exports** – Share your architecture by exporting diagrams to PlantUML, DGML, PNG, SVG, and more. - **Git History Hotspots** – Uncover hidden technical debt by running hotspot or change-coupling analyses directly on your Git repository history. - **Design Structure Matrix (DSM)** - Explore the System's dependencies at the type level. @@ -236,20 +237,17 @@ All metrics are accessible via the Analyzer Ribbon, and the results are presente ## Find dead code -*Analyzers → Dead Code* lists the elements nothing references any more. The rule works on the subtree, so a -class stays alive when one of its methods is used from the outside, and a class whose methods only call each -other is still dead. Only the topmost element of a dead subtree is listed. +C# Code Analyst can list the code elements that nothing references any more. -Calls through an interface count for the implementation behind it, so an implementation is not reported just -because it is only reached polymorphically — and a contract that nobody ever calls is reported together with -its implementations. +The rule works on the whole subtree, so a class stays alive when one of its methods is used from somewhere +else, and a class whose methods only call each other is still dead. Only the topmost element of a dead +subtree is listed. References the parser cannot see are flagged in the *Notes* column instead of being +dropped silently. -References the parser cannot see are not dropped silently: those rows carry a hint in the last column. An -empty hint means nothing speaks against deleting the element. Note that XAML is only half visible — event -handlers and `x:Name`d controls are compiled into C# and are found, while `{Binding}`, `{x:Static}` and -`{StaticResource}` end up in BAML and are resolved by reflection at runtime. +You can read more about the rule, how XAML is handled and where the limits are here: +[Dead Code](Documentation/dead-code.md) -Details and limitations: [Dead Code](Documentation/dead-code.md) +The analysis is accessible via the Analyzer Ribbon, and the result is presented in a table on a separate tab. ## Other languages diff --git a/Tests/UnitTests/Xaml/XamlGraphLinkerTests.cs b/Tests/UnitTests/Xaml/XamlGraphLinkerTests.cs new file mode 100644 index 00000000..953a9f28 --- /dev/null +++ b/Tests/UnitTests/Xaml/XamlGraphLinkerTests.cs @@ -0,0 +1,220 @@ +using CodeParserTests.Helper; +using CSharpCodeAnalyst.CodeGraph.Graph; +using CSharpCodeAnalyst.CodeParser.Xaml; + +namespace CodeParserTests.UnitTests.Xaml; + +[TestFixture] +public class XamlGraphLinkerTests +{ + [SetUp] + public void SetUp() + { + _graph = new TestCodeGraph(); + _directory = Path.Combine(Path.GetTempPath(), "XamlLinkerTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + private TestCodeGraph _graph = null!; + private string _directory = null!; + + /// + /// Builds "Assembly > Namespace > Type" the way the parser does: the namespace element carries + /// the whole dotted namespace, so the CLR name of the type is "App.Views.MyControl". The linker + /// resolves through the element names, so the ids stay short here. + /// + private CodeElement CreateType(CodeElement assembly, string namespaceName, string typeName) + { + var ns = assembly.Children.FirstOrDefault(c => c.Name == namespaceName) + ?? _graph.CreateNamespace(namespaceName, assembly); + return _graph.CreateClass(typeName, ns); + } + + private void WriteXaml(string relativePath, string content) + { + var path = Path.Combine(_directory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static string[] EdgesFrom(CodeElement source, CodeGraph graph) + { + return source.Relationships + .Where(r => r.HasAttribute(RelationshipAttribute.IsXamlReference)) + .Select(r => graph.Nodes[r.TargetId].FullName) + .ToArray(); + } + + [Test] + public void Link_ElementTag_ConnectsCodeBehindToTheUsedType() + { + var assembly = _graph.CreateAssembly("App"); + var view = CreateType(assembly, "App.Views", "MainWindow"); + var control = CreateType(assembly, "App.Controls", "MyGrid"); + + WriteXaml("MainWindow.xaml", """ + + + + """); + + var added = XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + Assert.Multiple(() => + { + Assert.That(added, Is.EqualTo(1)); + Assert.That(EdgesFrom(view, _graph), Is.EqualTo(new[] { "MyGrid" })); + }); + } + + [Test] + public void Link_XStatic_ConnectsToTheMemberNotOnlyTheType() + { + var assembly = _graph.CreateAssembly("App"); + var view = CreateType(assembly, "App.Views", "MainWindow"); + var strings = CreateType(assembly, "App.Resources", "Strings"); + var caption = _graph.CreateProperty("Caption", strings); + + WriteXaml("MainWindow.xaml", """ + + + + """); + + XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + Assert.That(EdgesFrom(view, _graph), Is.EqualTo(new[] { caption.FullName })); + } + + [Test] + public void Link_ResourceDictionary_GetsASyntheticClassNamedAfterTheFile() + { + var assembly = _graph.CreateAssembly("App"); + var converter = CreateType(assembly, "App.Converters", "BoolToBrush"); + + WriteXaml(Path.Combine("Styles", "ButtonStyles.xaml"), """ + + + + """); + + XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + var synthetic = assembly.Children.Single(c => c.Name == "Styles.ButtonStyles"); + + Assert.Multiple(() => + { + Assert.That(_graph.Nodes.ContainsKey(synthetic.Id), Is.True); + Assert.That(synthetic.SourceLocations.Single().File, Does.EndWith("ButtonStyles.xaml")); + Assert.That(EdgesFrom(synthetic, _graph), Is.EqualTo(new[] { "BoolToBrush" })); + }); + } + + [Test] + public void Link_FileWithoutAnyClrReference_CreatesNoSyntheticClass() + { + var assembly = _graph.CreateAssembly("App"); + + WriteXaml("Empty.xaml", """ + + """); + + var added = XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + Assert.Multiple(() => + { + Assert.That(added, Is.Zero); + Assert.That(assembly.Children, Is.Empty); + }); + } + + [Test] + public void Link_AssemblyQualifiedReference_ResolvesIntoTheOtherAssembly() + { + var app = _graph.CreateAssembly("App"); + var view = CreateType(app, "App.Views", "MainWindow"); + + var library = _graph.CreateAssembly("Sdk"); + var widget = CreateType(library, "Sdk.Controls", "Widget"); + + WriteXaml("MainWindow.xaml", """ + + + + """); + + XamlGraphLinker.Link(_graph, [new XamlProject(app, _directory)]); + + Assert.That(EdgesFrom(view, _graph), Is.EqualTo(new[] { "Widget" })); + } + + [Test] + public void Link_GeneratedOutputDirectories_AreSkipped() + { + var assembly = _graph.CreateAssembly("App"); + var control = CreateType(assembly, "App.Controls", "MyGrid"); + + const string xaml = """ + + + + """; + WriteXaml(Path.Combine("obj", "Copy.xaml"), xaml); + WriteXaml(Path.Combine("bin", "Copy.xaml"), xaml); + + var added = XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + Assert.That(added, Is.Zero); + } + + [Test] + public void Link_SameTypeUsedTwice_YieldsOneRelationshipWithBothLocations() + { + var assembly = _graph.CreateAssembly("App"); + var view = CreateType(assembly, "App.Views", "MainWindow"); + var control = CreateType(assembly, "App.Controls", "MyGrid"); + + WriteXaml("MainWindow.xaml", """ + + + + + """); + + XamlGraphLinker.Link(_graph, [new XamlProject(assembly, _directory)]); + + var relationship = view.Relationships.Single(); + + Assert.Multiple(() => + { + Assert.That(relationship.Type, Is.EqualTo(RelationshipType.Uses)); + Assert.That(relationship.SourceLocations, Has.Count.EqualTo(2)); + }); + } +} diff --git a/Tests/UnitTests/Xaml/XamlReferenceExtractorTests.cs b/Tests/UnitTests/Xaml/XamlReferenceExtractorTests.cs new file mode 100644 index 00000000..bdbdcb95 --- /dev/null +++ b/Tests/UnitTests/Xaml/XamlReferenceExtractorTests.cs @@ -0,0 +1,233 @@ +using CSharpCodeAnalyst.CodeParser.Xaml; + +namespace CodeParserTests.UnitTests.Xaml; + +[TestFixture] +public class XamlReferenceExtractorTests +{ + private static string[] TypeRefs(string xaml) + { + return XamlReferenceExtractor.Extract(xaml).References + .Where(r => r.MemberName is null) + .Select(r => r.TypeFullName) + .Distinct() + .ToArray(); + } + + private static string[] MemberRefs(string xaml) + { + return XamlReferenceExtractor.Extract(xaml).References + .Where(r => r.MemberName is not null) + .Select(r => $"{r.TypeFullName}.{r.MemberName}") + .Distinct() + .ToArray(); + } + + [Test] + public void Extract_ElementTag_IsResolvedThroughTheXmlnsPrefix() + { + const string xaml = """ + + + + """; + + Assert.That(TypeRefs(xaml), Is.EqualTo(new[] { "App.Shared.DynamicDataGrid.DynamicDataGrid" })); + } + + [Test] + public void Extract_ElementTagInsideDataTemplate_IsFoundToo() + { + // The case that started this: a named control inside a template gets no generated field, so the + // element tag is the only trace of the type. + const string xaml = """ + + + + + + + + """; + + Assert.That(TypeRefs(xaml), Is.EqualTo(new[] { "App.Grids.DynamicDataGrid" })); + } + + [Test] + public void Extract_XStatic_YieldsTypeAndMember() + { + const string xaml = """ + + + + """; + + Assert.That(MemberRefs(xaml), Is.EqualTo(new[] { "App.Resources.Strings.Close_Button" })); + } + + [Test] + public void Extract_NestedMarkupExtension_IsFound() + { + const string xaml = """ + + + + """; + + Assert.That(MemberRefs(xaml), Is.EqualTo(new[] { "App.Resources.Strings.Fallback" })); + } + + [Test] + public void Extract_XType_YieldsTypeOnly() + { + const string xaml = """ + +