em 03: extract-first scan + full suggestion report
- ExtractFirstScanner: reads-only scan of non-local/param read expressions (member/field/property access, element access, invocation, cast), deduped by symbol+text with occurrence counts; invocations flagged (hoisting changes eval count); own-class field/property reads marked optional. Assignment LHS skipped whole (decision 5) — surfaced as a report note. - SignatureBuilder: promotes bucket overlaps into one coherent signature (declared-inside vars drop from params; declared-inside return candidates become the return; outside candidates already covered by their ref write-back stay ref params). Trailing composite returns stay void with a note suggesting an extract-variable first. - ExtractionReporter + ReportFormatter: single composition shared by CLI and tests; report = header, params (ref), returns, locals, extract-first (count + flags), notes, suggested-signature line last. - DataFlowClassifier: Returns now carry types (ReturnSuggestion) — the signature line needs them. - Displays: compact symbol/type formatting for the report. - Fixture: RepeatReads (dedupe ×2 + skipped field write), Casts (cast candidate) appended at the end so all pinned line numbers stay put.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace ExtractMethod.Tooling;
|
||||
|
||||
/// <summary>
|
||||
/// One deduplicated "extract-first" candidate (parent bucket spec): a read
|
||||
/// expression that is NOT a simple local/parameter reference, so the classic
|
||||
/// refactoring sequence is to Extract-Variable it FIRST and re-run the tool —
|
||||
/// the extraction then receives a clean variable instead of a nested
|
||||
/// expression. <see cref="Text"/> plus <see cref="SymbolDisplay"/> identify
|
||||
/// the candidate; <see cref="Occurrences"/> is how often the same candidate
|
||||
/// (same symbol + same source text) is read inside the selection.
|
||||
/// </summary>
|
||||
/// <param name="Text">Source text of the expression (trivia-free).</param>
|
||||
/// <param name="SymbolDisplay">The bound symbol, minimally qualified for the
|
||||
/// report, or <c><unbound></c> when the expression resolves to no
|
||||
/// symbol (e.g. a cast — conversions are not symbols).</param>
|
||||
/// <param name="Occurrences">How often this exact candidate is read.</param>
|
||||
/// <param name="IsInvocation">True for method invocations: hoisting one into
|
||||
/// a variable changes HOW OFTEN it is evaluated (pure calls are safe to
|
||||
/// hoist, effectful ones are a behavior change — v1 cannot tell them apart,
|
||||
/// so every invocation is flagged).</param>
|
||||
/// <param name="OwnMemberKind"><c>field</c> or <c>property</c> when the
|
||||
/// accessed member belongs to the class that owns the selection — hoisting
|
||||
/// such an access is OPTIONAL because a same-class extracted method can still
|
||||
/// see it (it is still listed: extraction often precedes moving the method to
|
||||
/// another class, where the field would need to travel as a parameter).</param>
|
||||
public sealed record ExtractFirstEntry(
|
||||
string Text,
|
||||
string SymbolDisplay,
|
||||
int Occurrences,
|
||||
bool IsInvocation,
|
||||
string? OwnMemberKind)
|
||||
{
|
||||
/// <summary>Own-class field/property reads are optional to hoist (see <see cref="OwnMemberKind"/>).</summary>
|
||||
public bool Optional => OwnMemberKind is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The extract-first scan (yak em 03, parent bucket spec): find the READ
|
||||
/// expressions of the selection that are not simple local/parameter
|
||||
/// references — member/property/field access, element access, invocations,
|
||||
/// casts — dedupe them by semantic key (bound symbol + source text), count
|
||||
/// occurrences, flag invocations, and mark own-class field/property access
|
||||
/// as optional.
|
||||
///
|
||||
/// WHY a hand-rolled recursive walk instead of one DescendantNodes query:
|
||||
/// "which expression positions are READS" is exactly what data-flow analysis
|
||||
/// answers for VARIABLES (em 02's ReadInside), but for nested expressions
|
||||
/// the read/write distinction must come from the SYNTAX shape — and the scan
|
||||
/// needs three shape rules a flat query cannot express:
|
||||
/// 1. assignment LEFT sides are writes, skipped entirely (decision #5),
|
||||
/// 2. the direct callee of an invocation (and the container of an element
|
||||
/// access) is covered by that invocation/access — reporting `best.Bigger`
|
||||
/// AND `best.Bigger(...)` would double-count one call,
|
||||
/// 3. a member access's NAME is part of the access node, not an
|
||||
/// independent expression — visiting it as an IdentifierName would
|
||||
/// report the same access twice (e.g. `a.B.C` as both the member
|
||||
/// access and a bare `C`).
|
||||
///
|
||||
/// KNOWN v1 APPROXIMATIONS (documented, accepted):
|
||||
/// - Reads-only: non-variable assignment left-hand sides (`arr[i] = 5`) are
|
||||
/// skipped whole — including reads NESTED in them (`arr` in
|
||||
/// `arr[i].X = 5`). Surfaced as a report note (see
|
||||
/// <see cref="HasNonVariableAssignmentLeftSide"/>).
|
||||
/// - Compound-assignment LHS also reads its old value (`obj.Count += 1`);
|
||||
/// v1 skips it with the rest of the LHS.
|
||||
/// - Chained accesses report each link (`a.B.C` lists `a.B` and `a.B.C`)
|
||||
/// except the direct callee of a call, which the call covers.
|
||||
/// - Null-conditional chains are approximated: `a?.B` reports the member
|
||||
/// binding (`.B`) with its symbol; the receiver is not re-reported.
|
||||
/// - The scan classifies expression NODES; a statement's descendant TYPE
|
||||
/// references (`Widget best = ...`) are reached by the generic descent but
|
||||
/// filtered out because they resolve to types, not values.
|
||||
/// </summary>
|
||||
public static class ExtractFirstScanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans the selection and returns the deduplicated extract-first
|
||||
/// candidates in first-occurrence (source) order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ExtractFirstEntry> Scan(SemanticModel model, SelectionReport selection)
|
||||
{
|
||||
// The resolver guarantees Method is non-null on a successful report;
|
||||
// its containing type is what "own-class" is measured against.
|
||||
var enclosingType = (model.GetDeclaredSymbol(selection.Method!) as IMethodSymbol)?.ContainingType;
|
||||
|
||||
var entries = new List<ExtractFirstEntry>();
|
||||
var indexByKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
// Records one candidate occurrence. The semantic key is the bound
|
||||
// symbol PLUS the source text: the same member read through different
|
||||
// expressions (`widgets[0]` vs `widgets[i]`) stays separate, while an
|
||||
// identical expression read twice (`_seed` twice in RepeatReads)
|
||||
// dedupes into one entry with Occurrences > 1.
|
||||
void Report(ExpressionSyntax expr, bool invocation)
|
||||
{
|
||||
var info = model.GetSymbolInfo(expr);
|
||||
// Prefer the resolved symbol; on an ambiguous binding the first
|
||||
// candidate still identifies the member better than nothing.
|
||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||
|
||||
var text = expr.ToString(); // trivia-free expression text
|
||||
var key = $"{symbol?.ToDisplayString() ?? "<unbound>"}|{text}";
|
||||
var display = symbol is null ? "<unbound>" : Displays.Member(symbol);
|
||||
var ownKind = OwnMemberKind(symbol, enclosingType);
|
||||
|
||||
if (indexByKey.TryGetValue(key, out var index))
|
||||
{
|
||||
entries[index] = entries[index] with { Occurrences = entries[index].Occurrences + 1 };
|
||||
}
|
||||
else
|
||||
{
|
||||
indexByKey[key] = entries.Count;
|
||||
entries.Add(new ExtractFirstEntry(text, display, 1, invocation, ownKind));
|
||||
}
|
||||
}
|
||||
|
||||
// Own-class data member → hoisting is optional (parent bucket spec).
|
||||
// Exact containing-type match: base-class members would need a base
|
||||
// reference in the extracted method — not v1's "optional" case.
|
||||
static string? OwnMemberKind(ISymbol? symbol, INamedTypeSymbol? enclosingType)
|
||||
{
|
||||
if (enclosingType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return symbol switch
|
||||
{
|
||||
IFieldSymbol field when SymbolEqualityComparer.Default.Equals(field.ContainingType, enclosingType)
|
||||
=> "field",
|
||||
IPropertySymbol property when SymbolEqualityComparer.Default.Equals(property.ContainingType, enclosingType)
|
||||
=> "property",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Classifies a bare simple name. Simple names ARE reported when they
|
||||
// bind to anything that is not a variable or a type/namespace — that
|
||||
// is how the field read `_seed` and the property read `Scale` (both
|
||||
// written as bare identifiers) enter this bucket (decision #6 keeps
|
||||
// them out of the params bucket).
|
||||
void ClassifyIdentifier(IdentifierNameSyntax id)
|
||||
{
|
||||
if (id.IsVar)
|
||||
{
|
||||
return; // the contextual `var` keyword is not a value read
|
||||
}
|
||||
|
||||
var symbol = model.GetSymbolInfo(id).Symbol;
|
||||
switch (symbol)
|
||||
{
|
||||
case null:
|
||||
return; // unresolvable: nothing sensible to report
|
||||
case ILocalSymbol or IParameterSymbol:
|
||||
return; // the params bucket (decision #6), not extract-first
|
||||
case INamespaceSymbol or ITypeSymbol:
|
||||
return; // a type/namespace mention (e.g. `Widget`), not a value read
|
||||
case IDiscardSymbol:
|
||||
return; // discards are write sinks, never reads
|
||||
default:
|
||||
Report(id, invocation: false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>nameof is spelled like a call but reads no member state.</summary>
|
||||
static bool IsNameOf(InvocationExpressionSyntax inv)
|
||||
=> inv.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" };
|
||||
|
||||
void Visit(SyntaxNode node, bool suppress)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
// Decision #5: v1 is a READS-only scan. The whole LHS subtree
|
||||
// is skipped — writes to non-variables are invisible (the
|
||||
// report notes this when it applies; see
|
||||
// HasNonVariableAssignmentLeftSide). Nested reads inside an
|
||||
// LHS (the `arr` in `arr[i].X = 5`) are the documented loss.
|
||||
case AssignmentExpressionSyntax assignment:
|
||||
Visit(assignment.Right, suppress: false);
|
||||
break;
|
||||
|
||||
case InvocationExpressionSyntax inv when IsNameOf(inv):
|
||||
foreach (var arg in inv.ArgumentList.Arguments)
|
||||
{
|
||||
Visit(arg.Expression, suppress: false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case InvocationExpressionSyntax inv:
|
||||
// The invocation is the candidate; its direct callee is
|
||||
// covered by it (suppress = true below).
|
||||
if (!suppress)
|
||||
{
|
||||
Report(inv, invocation: true);
|
||||
}
|
||||
|
||||
Visit(inv.Expression, suppress: true);
|
||||
foreach (var arg in inv.ArgumentList.Arguments)
|
||||
{
|
||||
Visit(arg.Expression, suppress: false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ElementAccessExpressionSyntax elementAccess:
|
||||
if (!suppress)
|
||||
{
|
||||
Report(elementAccess, invocation: false);
|
||||
}
|
||||
|
||||
// The indexed CONTAINER is covered by the access itself
|
||||
// (same rule as the invocation callee above).
|
||||
Visit(elementAccess.Expression, suppress: true);
|
||||
foreach (var arg in elementAccess.ArgumentList.Arguments)
|
||||
{
|
||||
Visit(arg.Expression, suppress: false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case MemberAccessExpressionSyntax memberAccess:
|
||||
if (!suppress)
|
||||
{
|
||||
Report(memberAccess, invocation: false);
|
||||
}
|
||||
|
||||
// The member NAME is part of this node; only the receiver
|
||||
// is an independent read. Suppression never propagates
|
||||
// here: it marks exactly ONE node (the direct callee), so
|
||||
// `a.B.C(x)` still reports its `a.B` link.
|
||||
Visit(memberAccess.Expression, suppress: false);
|
||||
break;
|
||||
|
||||
case MemberBindingExpressionSyntax binding:
|
||||
// `a?.B`: the binding is the member access shape of a
|
||||
// null-conditional chain. No children to visit (the name
|
||||
// is part of this node).
|
||||
if (!suppress)
|
||||
{
|
||||
Report(binding, invocation: false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case CastExpressionSyntax cast:
|
||||
if (!suppress)
|
||||
{
|
||||
Report(cast, invocation: false);
|
||||
}
|
||||
|
||||
Visit(cast.Expression, suppress: false);
|
||||
break;
|
||||
|
||||
case IdentifierNameSyntax id:
|
||||
if (!suppress)
|
||||
{
|
||||
ClassifyIdentifier(id);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ParenthesizedExpressionSyntax parenthesized:
|
||||
// Parentheses must not change what "covered by the call"
|
||||
// means: `(best.Bigger)(...)` keeps the callee covered.
|
||||
Visit(parenthesized.Expression, suppress);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic descent (statement scaffolding, binaries,
|
||||
// unary/conditional operators, interpolation holes, ...):
|
||||
// none of these shapes is itself an extract-first
|
||||
// candidate, but their operand expressions are.
|
||||
foreach (var child in node.ChildNodes())
|
||||
{
|
||||
Visit(child, suppress: false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var statement in selection.Statements)
|
||||
{
|
||||
Visit(statement, suppress: false);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the selection assigns to something that is not a local or a
|
||||
/// parameter (a field, property, indexer, ...) — the decision-5 case the
|
||||
/// report must flag, because the reads-only scan cannot see that write.
|
||||
/// </summary>
|
||||
public static bool HasNonVariableAssignmentLeftSide(SemanticModel model, SelectionReport selection)
|
||||
{
|
||||
foreach (var statement in selection.Statements)
|
||||
{
|
||||
foreach (var node in statement.DescendantNodesAndSelf().OfType<AssignmentExpressionSyntax>())
|
||||
{
|
||||
var symbol = model.GetSymbolInfo(node.Left).Symbol;
|
||||
if (symbol is not ILocalSymbol and not IParameterSymbol)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user