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);
}