using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace ExtractMethod.Tooling; /// /// 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. plus identify /// the candidate; is how often the same candidate /// (same symbol + same source text) is read inside the selection. /// /// Source text of the expression (trivia-free). /// The bound symbol, minimally qualified for the /// report, or <unbound> when the expression resolves to no /// symbol (e.g. a cast — conversions are not symbols). /// How often this exact candidate is read. /// 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). /// field or property 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). public sealed record ExtractFirstEntry( string Text, string SymbolDisplay, int Occurrences, bool IsInvocation, string? OwnMemberKind) { /// Own-class field/property reads are optional to hoist (see ). public bool Optional => OwnMemberKind is not null; } /// /// 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 /// ). /// - 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. /// public static class ExtractFirstScanner { /// /// Scans the selection and returns the deduplicated extract-first /// candidates in first-occurrence (source) order. /// public static IReadOnlyList 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(); var indexByKey = new Dictionary(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() ?? ""}|{text}"; var display = symbol is null ? "" : 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; } } /// nameof is spelled like a call but reads no member state. 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; } /// /// 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. /// public static bool HasNonVariableAssignmentLeftSide(SemanticModel model, SelectionReport selection) { foreach (var statement in selection.Statements) { foreach (var node in statement.DescendantNodesAndSelf().OfType()) { var symbol = model.GetSymbolInfo(node.Left).Symbol; if (symbol is not ILocalSymbol and not IParameterSymbol) { return true; } } } return false; } }