Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CSharpCodeAnalyst.AnalyzerSdk/Search/SearchExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ public bool Evaluate(CodeElement? item)
return _conditions.Any(c => c.Evaluate(item));
}
}

/// <summary>
/// Negates a condition, so a search can exclude instead of select ("-Strings." hides everything
/// whose name contains "Strings.").
/// <para>
/// An item without a code element never matches, not even a negated condition. Every term
/// answers "no" for a null item, and negation must not silently turn that into a match - the
/// tree has a virtual root without a code element that would otherwise light up on every
/// exclusion.
/// </para>
/// </summary>
internal class Not : IExpression
{
private readonly IExpression _condition;

public Not(IExpression condition)
{
_condition = condition;
}

public bool Evaluate(CodeElement? item)
{
return item is not null && !_condition.Evaluate(item);
}
}
}

internal class FullNameSearch(string searchTerm) : Term(searchTerm)
Expand Down
33 changes: 31 additions & 2 deletions CSharpCodeAnalyst.AnalyzerSdk/Search/SearchExpressionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

public static class SearchExpressionFactory
{
/// <summary>
/// Marks a term as excluding rather than selecting. A minus sign cannot start a C# identifier, so
/// it is free to use here; imported graphs may contain one inside a name, but not at the start of
/// a search term.
/// </summary>
private const char NegationPrefix = '-';

private static Term CreateTerm(string search, TextSearchField searchField)
{
if (searchField == TextSearchField.FullName)
Expand All @@ -12,7 +19,29 @@ private static Term CreateTerm(string search, TextSearchField searchField)
return new NameSearch(search);
}

public static IExpression CreateSearchExpression(string searchText, TextSearchField searchField = TextSearchField.FullName)
/// <summary>
/// Wraps the term in a negation when it starts with '-'. The negation belongs to its own term, so
/// it binds tighter than the AND of a group and than the OR between groups: "-a b | c" reads as
/// "((NOT a) AND b) OR c". A lone '-' has nothing to negate and stays a literal search term.
/// </summary>
private static IExpression CreateTermOrNegation(string token, TextSearchField searchField, bool allowNegation)
{
if (allowNegation && token.Length > 1 && token[0] == NegationPrefix)
{
return new Term.Not(CreateTerm(token[1..], searchField));
}

return CreateTerm(token, searchField);
}

/// <param name="allowNegation">
/// Whether a leading '-' excludes the term. Pass false where an expression that matches almost
/// everything is harmful rather than useful: the tree expands and highlights every ancestor of a
/// match, so an exclusion would unfold the whole tree at once. With negation off the '-' is part
/// of the search term like any other character.
/// </param>
public static IExpression CreateSearchExpression(string searchText,
TextSearchField searchField = TextSearchField.FullName, bool allowNegation = true)
{
// Or binds less.
var orTerms = searchText
Expand All @@ -24,7 +53,7 @@ public static IExpression CreateSearchExpression(string searchText, TextSearchFi
{
var andExpressions = orTerm
.Split([' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(IExpression (t) => CreateTerm(t, searchField))
.Select(t => CreateTermOrNegation(t, searchField, allowNegation))
.ToArray();

orExpressions.Add(new Term.And(andExpressions));
Expand Down
69 changes: 69 additions & 0 deletions CSharpCodeAnalyst.Analyzers/DeadCode/Analyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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;
using CSharpCodeAnalyst.CodeGraph.Declarations;

namespace CSharpCodeAnalyst.Analyzers.DeadCode;

/// <summary>
/// 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.
/// </summary>
public class Analyzer : IAnalyzer
{
private readonly ExternalContractStore _externalContracts;
private readonly IPublisher _messaging;
private readonly IUserNotification _userNotification;

public Analyzer(IPublisher messaging, IUserNotification userNotification,
ExternalContractStore externalContracts)
{
_messaging = messaging;
_userNotification = userNotification;
_externalContracts = externalContracts;
}

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, _externalContracts);

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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
{
/// <summary>Beyond this many related members the hint only states the count - the cell has to stay readable.</summary>
private const int MaxNamedRelatedMembers = 3;

internal DeadCodeRowViewModel(DeadCodeFinding finding)
{
Element = finding.Element;
Name = finding.Element.FullName;
Kind = finding.Element.ElementType.ToString();
// Fully qualified: WPF pulls a global "Accessibility" namespace into scope; ours is AccessLevel.
Access = finding.Element.AccessLevel == CodeGraph.Graph.AccessLevel.Unknown
? string.Empty
: finding.Element.AccessLevel.ToString();

Confidence = finding.Confidence.ToString();

// Bound for the colour rating and for sorting; the column displays the word.
ConfidenceValue = (int)finding.Confidence;
Hint = FormatHint(finding);
}

/// <summary>The underlying graph node, used to jump to the source and to add it to the Code Explorer.</summary>
public CodeElement Element { get; }

public string Name { get; }
public string Kind { get; }

/// <summary>The element's visibility, empty when the producer did not supply one.</summary>
public string Access { get; }

public string Confidence { get; }

/// <summary>Numeric backer of <see cref="Confidence" /> for the colour rating and for sorting.</summary>
public int ConfidenceValue { get; }

/// <summary>
/// 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.
/// </summary>
public string Hint { get; }

private static string FormatHint(DeadCodeFinding finding)
{
var parts = new List<string>();

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.ImplementsExternalContract))
{
parts.Add(string.Format(Strings.DeadCode_Hint_ImplementsExternalContract, finding.ExternalContract));
}

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));
}
}
141 changes: 141 additions & 0 deletions CSharpCodeAnalyst.Analyzers/DeadCode/Presentation/DeadCodeViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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<TableRow> _rows;

internal DeadCodeViewModel(List<DeadCodeFinding> findings, IPublisher messaging)
{
_messaging = messaging;

// Highest confidence first: that is the part of the result you can work through without checking
// every entry by hand, so it belongs at the top before anyone touches a column header. The
// analysis already sorts by name, which stays the tie breaker within a confidence band.
var rows = findings
.OrderByDescending(f => f.Confidence)
.Select(f => new DeadCodeRowViewModel(f));
_rows = new ObservableCollection<TableRow>(rows);
}

public override bool CanFilter => true;

public override IEnumerable<TableColumnDefinition> GetColumns()
{
return new List<TableColumnDefinition>
{
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()
{
Type = ColumnType.Text,
Header = Strings.Column_DeadCode_Access,
PropertyName = nameof(DeadCodeRowViewModel.Access),
Width = 80
},
new()
{
Type = ColumnType.Text,
Header = Strings.Column_DeadCode_Confidence,
PropertyName = nameof(DeadCodeRowViewModel.Confidence),
Width = 80,

// High (2) green, Medium (1) orange, Low (0) red - here a larger value is better.
Rating = new ThresholdRating(2, 1, false),
RatingValuePropertyName = nameof(DeadCodeRowViewModel.ConfidenceValue),
SortMemberName = nameof(DeadCodeRowViewModel.ConfidenceValue)
},
new()
{
// 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)
}
};
}

public override ObservableCollection<TableRow> GetData()
{
return _rows;
}

/// <summary>
/// Filters by element name using the same search expression as the Advanced Search (camel-case,
/// OR via '|', AND via spaces, exclusion via a leading '-'). Exclusion is what makes a long result
/// usable: "-Strings. -Tests" drops whole groups of findings at once.
/// </summary>
public override ObservableCollection<TableRow> Filter(string searchText)
{
if (string.IsNullOrWhiteSpace(searchText))
{
return _rows;
}

var expression = SearchExpressionFactory.CreateSearchExpression(searchText);
var filtered = _rows
.Cast<DeadCodeRowViewModel>()
.Where(row => expression.Evaluate(row.Element));
return new ObservableCollection<TableRow>(filtered);
}

public override DataTemplate? GetRowDetailsTemplate()
{
return null;
}

public override List<CommandDefinition> GetCommands()
{
return
[
new CommandDefinition
{
Header = Strings.JumpToCode,
Command = new WpfCommand<DeadCodeRowViewModel>(JumpToCode, CanJumpToCode)
},
new CommandDefinition
{
Header = Strings.CopyToExplorerGraph_MenuItem,
Command = new WpfCommand<DeadCodeRowViewModel>(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]));
}
}
Loading