Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The projects wired together in `CSharpCodeAnalyst.sln` (the load-bearing ones):

`DartExtractor/` is a standalone **Dart** package (not in the .NET solution) used by the Dart/Flutter import — see **Dart/Flutter import** below.

`TestSuite/` is a handcrafted C# solution used purely as parser input for the approval tests, and `TestSuiteDart/` is its Dart equivalent for the Dart import. Do not consume either from production code — they are intentionally full of odd language constructs. `ReferencedAssemblies/` contains the MSAGL DLLs referenced directly by `CSharpCodeAnalyst.csproj` and `Tests.csproj` (MSAGL is not on NuGet for the versions used here).
`TestSuite/` is a handcrafted C# solution used purely as parser input for the approval tests, and `TestSuiteDart/` is its Dart equivalent for the Dart import. Do not consume either from production code — they are intentionally full of odd language constructs. `TestSuiteGenerated/` is a third fixture solution, for the **source-generator** path only (`SourceGeneratorFixtureTests`): it uses `[GeneratedRegex]`, which ships with the SDK, so it needs no package reference and neither a restore nor a build — MSBuildWorkspace's design-time build runs the generator. None of the three is part of `CSharpCodeAnalyst.sln`. `ReferencedAssemblies/` contains the MSAGL DLLs referenced directly by `CSharpCodeAnalyst.csproj` and `Tests.csproj` (MSAGL is not on NuGet for the versions used here).

## Architectural notes worth knowing before editing

Expand Down Expand Up @@ -93,7 +93,7 @@ Analyzers are the boxes under the **Analyzers** ribbon button. Each implements `
Data flow, end to end:
1. **Algorithm** in `CodeGraph/Algorithms/...` takes the `CodeGraph` and returns a plain result object. Type-level analyses lift relationships to the containing type, deduplicate, and exclude `IsExternal` nodes (see `TypeDependencyAnalysis` / `SystemMetricsAnalysis` as the reference); reuse `Type.IsDependency()` to decide which edges count.
2. **`Analyze`** runs the algorithm; on an empty result it calls `_userNotification.ShowSuccess(...NoData)` and returns, otherwise it builds a **table view model** and publishes `new ShowTabularDataRequest(Id, Name, vm)` on the message bus.
3. **Table VM** derives from `Table` (`AnalyzerSdk/DynamicDataGrid/Contracts/TabularData/`): `GetColumns()` returns `TableColumnDefinition`s (each binds a `PropertyName` on the row VM), `GetData()` returns the `TableRow`s. Optional: `CanFilter`/`Filter`, `GetCommands()` (context-menu / double-click actions), row-details template, and per-column `Rating` (an `IMetricRating` → colored cell background, see `ThresholdRating` and `RatingToBrushConverter`).
3. **Table VM** derives from `Table` (`AnalyzerSdk/DynamicDataGrid/Contracts/TabularData/`): `GetColumns()` returns `TableColumnDefinition`s (each binds a `PropertyName` on the row VM), `GetData()` returns the `TableRow`s. Optional: `CanFilter`/`Filter`, `GetCommands()` (context-menu / double-click actions), row-details template, and per-column `Rating` (an `IMetricRating` → colored cell background, see `ThresholdRating` and `RatingToBrushConverter`). **"Copy table as CSV" comes for free** — `DynamicDataGrid` appends it to every table's context menu and copies the rows currently on screen (after filtering and sorting) via `TableCsv`; no analyzer implements its own export.
4. **Row VM** derives from `TableRow` and exposes one property per column (plus a `SortMemberName`/`RatingValuePropertyName` numeric backer when the displayed column is a formatted string).
5. **Register** the analyzer in `CSharpCodeAnalyst/Features/Analyzers/AnalyzerManager.LoadAnalyzers` (add a `using <Feature> = ...` alias and an `_analyzers.Add`). **No XAML change** is needed: the ribbon `RibbonSplitButton` binds `ItemsSource` to `MainViewModel.Analyzers` (= `AnalyzerManager.All`) and runs `ExecuteAnalyzerCommand` with the analyzer `Id`; `MainViewModel` publishes the result into a `DynamicTab` that hosts a `DynamicDataGrid`.
6. **Strings** live in `CSharpCodeAnalyst.Analyzers/Resources/Strings.resx` **and** its hand-maintained `Strings.Designer.cs` (add the getter yourself). Convention: `Analyzer_<Id>_Label` / `_Tooltip` / `_NoData`, `Column_<Id>_<Col>`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Globalization;
using System.Text;

namespace CSharpCodeAnalyst.AnalyzerSdk.DynamicDataGrid.Contracts.TabularData;

/// <summary>
/// Turns a table into CSV, so a result can be counted, sorted or pivoted somewhere else. Separate from
/// the grid that offers it, because the interesting part - quoting, and reading a cell the way the
/// column binds it - is worth testing on its own.
/// </summary>
public static class TableCsv
{
/// <param name="columns">The columns, in display order. Their <c>PropertyName</c> reads the cell.</param>
/// <param name="rows">
/// The rows to write. The caller passes what is on screen - filtered and sorted - rather than the
/// table's full data, because narrowing the result down is usually the first step.
/// </param>
/// <param name="separator">
/// Null takes the list separator of the current culture, which is what a spreadsheet on the same
/// machine expects: a comma in en-US, a semicolon in de-DE. With the wrong one the paste lands in a
/// single column.
/// </param>
public static string Build(IEnumerable<TableColumnDefinition> columns, IEnumerable<object> rows,
string? separator = null)
{
ArgumentNullException.ThrowIfNull(columns);
ArgumentNullException.ThrowIfNull(rows);

separator ??= CultureInfo.CurrentCulture.TextInfo.ListSeparator;

var columnList = columns.ToList();
var csv = new StringBuilder();

csv.AppendLine(string.Join(separator, columnList.Select(column => Escape(column.Header, separator))));

foreach (var row in rows)
{
csv.AppendLine(string.Join(separator,
columnList.Select(column => Escape(ReadCell(row, column.PropertyName), separator))));
}

return csv.ToString();
}

/// <summary>
/// Reads the property the column binds to. A column without a property name - a button, an image -
/// contributes an empty cell instead of breaking the row.
/// </summary>
private static string ReadCell(object row, string? propertyName)
{
if (string.IsNullOrEmpty(propertyName))
{
return string.Empty;
}

var value = row.GetType().GetProperty(propertyName)?.GetValue(row);

return value switch
{
null => string.Empty,

// The same culture the cell was rendered with, so a number reads the same in both places.
IFormattable formattable => formattable.ToString(null, CultureInfo.CurrentCulture),
_ => value.ToString() ?? string.Empty
};
}

/// <summary>
/// RFC 4180: quote a value that holds the separator, a quote or a line break, and double the quotes
/// inside it. Names and hints in this application contain all three.
/// </summary>
private static string Escape(string? value, string separator)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}

if (!value.Contains(separator, StringComparison.Ordinal) && !value.Contains('"') &&
!value.Contains('\n') && !value.Contains('\r'))
{
return value;
}

return $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}
}
38 changes: 37 additions & 1 deletion CSharpCodeAnalyst.AnalyzerSdk/Search/SearchExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ protected Term(string searchTerm)
{
SearchMode = SearchType.ExternalCode;
}
else if (lowerSearchTerm is "source:generated")
{
// Code a tool wrote. Mostly useful negated ("-source:generated"), because a resx designer or
// the XAML markup compiler contributes rows nobody can act on.
SearchMode = SearchType.GeneratedCode;
}
else
{
var (isPascalCase, regex) = PascalCaseSearch.CreateSearchRegex(searchTerm);
Expand Down Expand Up @@ -88,7 +94,10 @@ internal enum SearchType

FullNameResharperStyle,
ExternalCode,
InternalCode
InternalCode,

// Written by a tool rather than a person (CodeElement.IsGenerated).
GeneratedCode
}

internal class And : IExpression
Expand Down Expand Up @@ -120,6 +129,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 All @@ -136,6 +170,7 @@ public override bool Evaluate(CodeElement? item)
SearchType.Type => item.ElementType == Type,
SearchType.InternalCode => !item.IsExternal,
SearchType.ExternalCode => item.IsExternal,
SearchType.GeneratedCode => item.IsGenerated,
SearchType.FullNameResharperStyle => Regex!.IsMatch(item.FullName),
_ => item.FullName.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase)
};
Expand All @@ -156,6 +191,7 @@ public override bool Evaluate(CodeElement? item)
SearchType.Type => item.ElementType == Type,
SearchType.InternalCode => !item.IsExternal,
SearchType.ExternalCode => item.IsExternal,
SearchType.GeneratedCode => item.IsGenerated,
SearchType.FullNameResharperStyle => Regex!.IsMatch(item.Name),
_ => item.Name.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase)
};
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);
}
}
Loading