using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace ExtractMethod.Tooling; /// /// The generated extract-method refactoring (yak em 04 codegen): the NEW /// method (the em 03 promoted signature; parameter names come straight from /// the source symbols), the CALL STATEMENTS that replace the selection in the /// enclosing method, and the fully transformed syntax tree. /// /// The extracted method, ready to be read. Built /// with SyntaxFactory and NORMALIZED (whitespace) before insertion, so its /// text is the readable shape that lands in the file — not the trivia-less /// builder output. /// The statement(s) that replace the selected /// run at the call site (one statement, or a capture + destructure pair for /// a mixed tuple return), normalized like . /// The whole input tree with the selection /// moved out and the new method added next to the enclosing one — the /// round-trip check compiles exactly this tree. public sealed record Refactoring( MethodDeclarationSyntax NewMethod, IReadOnlyList CallStatements, SyntaxTree TransformedTree) { public string TransformedText => TransformedTree.ToString(); } /// /// Emits the refactoring behind the em 03 suggestion. /// /// WHY no identifier rewriting at all: the suggested parameter names ARE the /// source variable names (em 02/03 promotion), so the moved statements bind /// unchanged — every local/parameter read inside the selection is a parameter /// of the new method, every variable declared inside moves with the body, and /// the remaining reads are type members visible from the same class /// (the new method is inserted into the SAME containing type). This is the /// property the resolver (one method body, whole statements) and the /// classifier (params bucket = exactly the local/parameter reads) jointly /// guarantee; a generic SyntaxRewriter renaming pass would be dead weight in /// v1. What IS generated structurally: /// - the new method declaration (signature line, static-ness mirrored from /// the enclosing method, body = selected statements + appended /// return ... for return slots the selection did not already end /// with a return of), /// - the call statement(s) replacing the selection, /// - the insertion of the new method right after the enclosing one. /// /// CALL-SITE RULES (per promoted return slots, v1): /// - void → a plain call expression statement. A ref parameter already flows /// the value back (e.g. Accumulate: Extract(ref acc, n);). /// - one slot DECLARED inside the selection → redeclared at the call site /// from the result: int total = Extract(limit);. /// - one slot declared OUTSIDE → plain assignment: acc = Extract(...);. /// - several slots ALL declared inside → tuple deconstruction: /// var (x, y) = Extract(n);. /// - several slots, any declared outside → capture + ItemN assignments /// (the result is evaluated ONCE, then assigned member-wise). /// - the enclosing method's return type is non-void and the selection ends /// the method → the call site must be return Extract(...); and the /// suggested return type must equal the enclosing one (otherwise: refuse). /// /// REFUSALS (loud, never a silent wrong refactoring): /// - the selection contains a return that does NOT end the enclosing method /// (v1: select to the end of the method); /// - the suggested return type does not match the enclosing method's return /// type — including the em 03 "composite trailing return" case, where the /// signature is void although the enclosing method returns a value (the /// report's note already says: extract a local first, then re-run). /// /// BUILDER STYLE: single-slot declarations and assignments are hand-built /// with SyntaxFactory; the deconstruction call var (x, y) = ...; and /// the tuple return return (x, y); are PARSED from a text skeleton and /// the call slot swapped in via ReplaceNode — the parser knows the /// designation/tuple-element shapes a v1 builder would get wrong. (Parse /// diagnostics would surface as red nodes and fail the round-trip check /// loudly, not silently.) /// /// KNOWN v1 APPROXIMATIONS: type display strings are re-parsed for the /// generated declarations (a tuple return with a comma-bearing generic type /// element is not supported); the capture name result could in theory /// collide with an enclosing local; the name check "suggested return type == /// enclosing return type" is a string comparison, not symbol equality. /// public static class RefactoringGenerator { public static Refactoring Generate(SemanticModel model, SelectionReport selection, SignatureSuggestion signature) { var method = selection.Method!; var body = method.Body!; var declaredInside = SignatureBuilder.DeclaredInsideNames(model, selection); var slots = signature.ReturnSlots.ToList(); var slotNames = slots.Select(s => s.Name).ToList(); var containsReturn = selection.Statements.OfType().Any(); var selectionEndsBody = ReferenceEquals(selection.Statements[^1], body.Statements[^1]); var enclosingNonVoid = method.ReturnType is not PredefinedTypeSyntax voidType || !voidType.Keyword.IsKind(SyntaxKind.VoidKeyword); var enclosingReturnType = method.ReturnType.ToString(); // ---- soundness refusals (see class comment) ---- if (containsReturn && enclosingNonVoid) { if (!selectionEndsBody) { throw new InvalidOperationException( "the selection contains a return that does not end the enclosing method — select to the end of the method (v1 codegen)"); } if (signature.ReturnType != enclosingReturnType) { throw new InvalidOperationException( signature.ReturnType == "void" ? "the selection ends with the method's return, but its value is not nameable in v1 — " + "extract it into a local first (see the report note), then re-run" : $"the suggested return type {signature.ReturnType} does not match the enclosing method's " + $"return type {enclosingReturnType} — refusing to generate"); } } // ---- the call expression (parameter names = source names, ref-ness kept) ---- var arguments = signature.Params .Select(p => { var argument = SyntaxFactory.Argument(SyntaxFactory.IdentifierName(p.Name)); return p.ByRef ? argument.WithRefOrOutKeyword(SyntaxFactory.Token(SyntaxKind.RefKeyword)) : argument; }) .ToList(); var invocation = SyntaxFactory.InvocationExpression( SyntaxFactory.IdentifierName(signature.MethodName)) .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))) // Normalized once, here: the PARSED deconstruction skeleton (see // BuildCallStatements) must not be re-normalized (the normalizer // drops the space after the contextual keyword `var` before `(`), // so it receives the invocation already properly spaced. .NormalizeWhitespace(); // Normalizing only the GENERATED nodes keeps the original trivia — // and therefore the doc comments — untouched (a whole-root // NormalizeWhitespace corrupts doc-comment trivia in a way Roslyn's // XML-doc writer rejects with CS1569, and it would also reformat the // entire file, making the printed diff useless). The call statements // and the new method get their indentation/end-of-line grafted from // the selection they replace, so the edit slots into the original // formatting. var callStatements = BuildCallStatements(slots, slotNames, invocation, declaredInside, callMustReturn: containsReturn && enclosingNonVoid).ToList(); var indent = SyntaxFactory.TriviaList( selection.Statements[0].GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia))); for (var index = 0; index < callStatements.Count; index++) { var call = callStatements[index].WithLeadingTrivia(indent); if (index == callStatements.Count - 1) { // The replaced statement's trailing end-of-line kept the // following token (the next statement or the closing brace) // on its own line — carry it over. call = call.WithTrailingTrivia(selection.Statements[^1].GetTrailingTrivia()); } callStatements[index] = call; } // Normalize the new method INSIDE a throwaway class wrapper: the // normalizer computes indentation from nesting depth, and a bare // method node has none — its body would come out flush-left. At class // depth the signature lands one level in and the body two. The grafted // leading/trailing trivia below replaces whatever the wrapper put // around the member. var newMethod = (MethodDeclarationSyntax)SyntaxFactory.ClassDeclaration("__normalizer__") .WithMembers(SyntaxFactory.SingletonList( BuildNewMethod(method, selection, signature, slots, slotNames))) .NormalizeWhitespace() .Members[0]; var methodIndent = method.GetLeadingTrivia() .LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia)); newMethod = newMethod .WithLeadingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine("\n"), methodIndent)) .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n")); // ---- apply to the tree: swap the selection for the call, add the method ---- var allStatements = body.Statements; var firstIndex = allStatements.IndexOf(selection.Statements[0]); // reference identity: same tree var newStatements = new List(); newStatements.AddRange(allStatements.Take(firstIndex)); newStatements.AddRange(callStatements); newStatements.AddRange(allStatements.Skip(firstIndex + selection.Statements.Count)); var rewrittenMethod = method.WithBody(body.WithStatements(SyntaxFactory.List(newStatements))); // The new method lands right after the enclosing one, in the SAME type // (that is what keeps the extract-first own-class members in scope). var containingType = method.AncestorsAndSelf().OfType().First(); var memberIndex = containingType.Members.IndexOf(method); var newType = containingType.WithMembers( containingType.Members.Replace(method, rewrittenMethod).Insert(memberIndex + 1, newMethod)); var tree = model.SyntaxTree; var visited = new SingleNodeReplacer(containingType, newType).Visit(tree.GetRoot()); if (visited is null) { throw new InvalidOperationException("the type replacement rewrote the root away (internal error)"); } return new Refactoring(newMethod, callStatements, tree.WithRootAndOptions(visited, tree.Options)); } /// /// The one place a SyntaxRewriter enters this tool (yak em 04's stated /// curriculum): swapping a single node by REFERENCE identity. The typed /// With*-slices (WithBody, WithMembers) cover the local changes; only the /// "put the rewritten type back into the root" step is a whole-tree walk. /// private sealed class SingleNodeReplacer : CSharpSyntaxRewriter { private readonly SyntaxNode _oldNode; private readonly SyntaxNode _newNode; public SingleNodeReplacer(SyntaxNode oldNode, SyntaxNode newNode) { _oldNode = oldNode; _newNode = newNode; } public override SyntaxNode? Visit(SyntaxNode? node) => ReferenceEquals(node, _oldNode) ? _newNode : base.Visit(node); } /// The call statement(s) replacing the selection (see class comment). private static IReadOnlyList BuildCallStatements( IReadOnlyList slots, IReadOnlyList slotNames, InvocationExpressionSyntax invocation, ISet declaredInside, bool callMustReturn) { if (callMustReturn) { // The selection ends the enclosing (non-void) method: the call is // the return value, the slots live in the new method. return [Norm(SyntaxFactory.ReturnStatement(invocation))]; } if (slots.Count == 0) { return [Norm(SyntaxFactory.ExpressionStatement(invocation))]; } if (slots.Count == 1) { var slot = slots[0]; if (declaredInside.Contains(slot.Name)) { // Declared inside the selection: redeclare it at the call site // from the result (the declaration statement moved with the body). var declarator = SyntaxFactory.VariableDeclarator(slot.Name) .WithInitializer(SyntaxFactory.EqualsValueClause(invocation)); var declaration = SyntaxFactory.VariableDeclaration( ParseType(slot.Type), SyntaxFactory.SingletonSeparatedList(declarator)); return [Norm(SyntaxFactory.LocalDeclarationStatement(declaration))]; } // Declared outside: the caller already owns it (a parameter, or a // local declared before the selection) — plain assignment. return [Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName(slot.Name), invocation)))]; } // Several return slots. if (slots.All(s => declaredInside.Contains(s.Name))) { // All declared inside: tuple deconstruction at the call site. // Parsed from a skeleton (see class comment — builder style), the // call swapped in at the placeholder identifier. NOT normalized: // the skeleton text is already well-spaced, and NormalizeWhitespace // would drop the space after the contextual keyword `var` (it is // an identifier token to the normalizer, so `var (` becomes // `var(`). return [ParseStatementWithCall($"var ({string.Join(", ", slotNames)}) = __call__;", invocation)]; } // Mixed: evaluate the call ONCE into a capture, then assign each slot // from the matching ItemN. (Capture name collision with an enclosing // local is a documented v1 limitation.) var statements = new List { Norm(SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VarKeyword)), SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("result") .WithInitializer(SyntaxFactory.EqualsValueClause(invocation)))))), }; for (var index = 0; index < slots.Count; index++) { statements.Add(Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName(slots[index].Name), SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, SyntaxFactory.IdentifierName("result"), SyntaxFactory.IdentifierName($"Item{index + 1}")))))); } return statements; } /// /// Normalizes a builder-made statement: SyntaxFactory tokens carry no /// trivia, so without this return and Extract would render /// glued together. Only used for BUILDER statements — the parsed skeleton /// statements are already well-spaced (see the deconstruction comment). /// private static StatementSyntax Norm(StatementSyntax statement) => statement.NormalizeWhitespace(); /// /// Parses (containing the placeholder /// identifier __call__) and swaps the placeholder for the built /// — the parser supplies the syntax shapes /// (tuple designations, tuple elements) the v1 builder avoids. /// private static StatementSyntax ParseStatementWithCall(string statementText, InvocationExpressionSyntax invocation) { var parsed = SyntaxFactory.ParseStatement(statementText); var placeholder = parsed.DescendantNodes() .OfType() .First(n => n.Identifier.ValueText == "__call__"); return parsed.ReplaceNode(placeholder, invocation); } /// /// The extracted method: the promoted signature, static-ness mirrored from /// the enclosing method, body = the selected statements plus an appended /// return for the return slots (skipped when the selection already /// ends in a return — a second one would be unreachable). The appended /// tuple return is parsed from its text skeleton (see class comment). /// private static MethodDeclarationSyntax BuildNewMethod( MethodDeclarationSyntax enclosingMethod, SelectionReport selection, SignatureSuggestion signature, IReadOnlyList slots, IReadOnlyList slotNames) { // A static enclosing method can only read statics and parameters, so // the extracted body needs no instance — mirror the static modifier. var modifiers = enclosingMethod.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)) ? new[] { SyntaxFactory.Token(SyntaxKind.StaticKeyword) } : Array.Empty(); var parameters = signature.Params .Select(p => { var parameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(p.Name)) .WithType(ParseType(p.Type)); return p.ByRef ? parameter.WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.RefKeyword))) : parameter; }) .ToList(); var bodyStatements = selection.Statements.ToList(); if (slots.Count > 0 && selection.Statements[^1] is not ReturnStatementSyntax) { // The slot order here is the same name-ordered sequence the tuple // ReturnType was built from, so the emitted value matches the // declared element types. bodyStatements.Add(slots.Count == 1 ? SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName(slotNames[0])) : SyntaxFactory.ParseStatement($"return ({string.Join(", ", slotNames)});")); } return SyntaxFactory.MethodDeclaration(ParseType(signature.ReturnType), SyntaxFactory.Identifier(signature.MethodName)) .WithModifiers(SyntaxFactory.TokenList(modifiers)) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters))) .WithBody(SyntaxFactory.Block(SyntaxFactory.List(bodyStatements))); } /// /// Re-parses a type display string (em 02/03 are plain strings) in type /// context — ParseTypeName handles plain names, generics and tuple /// display strings (T1, T2) alike. /// /// EXCEPT void: parsing "void" in TYPE context yields a node the /// compiler rejects as a method return type with CS1547 (keyword 'void' /// cannot be used in this context) — the parser that reads real method /// declarations produces PredefinedType(Token(VoidKeyword)), so /// that is what a void signature must be built from. /// Single choke point for the string-to-syntax hop; its correctness is /// enforced by the round-trip check and the tests, not by construction. /// private static TypeSyntax ParseType(string text) => text == "void" ? SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)) : SyntaxFactory.ParseTypeName(text); }