RefactoringGenerator emits the new method (promoted signature, parameter
names = source symbol names) plus the rewritten call site for every
em 02/03 bucket shape (return slot declared inside/outside, tuple
deconstruction, ref write-back, return-call when the selection ends a
non-void method) and refuses loudly where v1 cannot be sound (composite
trailing return, return not ending the method).
The transformed tree must compile with zero diagnostics (the yak's
round-trip check, enforced in both the CLI and the tests).
Findings that shaped the implementation, all enforced by tests:
- NormalizeWhitespace on the WHOLE root corrupts doc-comment trivia so
Roslyn's XML-doc writer fails with CS1569 ('count (-3) must be
non-negative') — and it would reformat the entire file, killing the
diff. Only generated nodes are normalized; original trivia is kept and
indentation/end-of-line is grafted onto the inserted statements/method.
- ParseTypeName("void") yields a node rejected as a method return type
(CS1547) — a void signature must be PredefinedType(Token(VoidKeyword)).
- NormalizeWhitespace drops the space after the contextual keyword 'var'
before '(' — the parsed 'var (x, y) = ...' deconstruction skeleton is
used verbatim; builder-made statements are normalized.
- The new method is normalized inside a throwaway class wrapper: the
normalizer computes indentation from nesting depth, and a bare method
has none.
v1 decision: print a unified-diff PREVIEW to stdout (hand-rolled LCS
unified diff), never an in-place rewrite. Also fixes the hunk header
off-by-one from the phantom trailing empty line of Split('\n').
227 lines
9.8 KiB
C#
227 lines
9.8 KiB
C#
using ExtractMethod.Tooling;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
|
|
namespace BeforeAfter.Tests.ExtractMethod;
|
|
|
|
/// <summary>
|
|
/// Tests for yak em 04 (codegen): generate the refactoring from the em 03
|
|
/// suggestion — new method (promoted signature, parameter names from the
|
|
/// source symbols) + rewritten call site + transformed tree — and the
|
|
/// round-trip check (the transformed tree compiles with zero diagnostics).
|
|
///
|
|
/// Fixture line anchors: the em 01/02/03 anchors (ScoreReads 56..58,
|
|
/// Summarize 68..76 / partial 68..72, Heaviest 87..93) plus the em 04
|
|
/// additions Accumulate for-loop 127..130 and Pairwise 141..147.
|
|
/// </summary>
|
|
public class RefactoringTests
|
|
{
|
|
// ---------------------------------------------------------------------
|
|
// Summarize partial (68..72): the declared-inside `total` promotes to the
|
|
// plain return of the new method; at the call site it is re-declared from
|
|
// the result. The new body gains the appended `return total;`.
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void Summarize_partial_selection_generates_returning_method_and_declaration_call()
|
|
{
|
|
var (method, calls, tree) = Generate(68, 72);
|
|
|
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
|
Assert.Equal("int", method.ReturnType.ToString());
|
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
|
|
|
var body = (BlockSyntax)method.Body!;
|
|
Assert.Equal(3, body.Statements.Count); // total declaration, for-loop, appended return
|
|
Assert.Equal("return total;", body.Statements[2].ToString());
|
|
|
|
// `total` was declared inside the selection: re-declared at the call
|
|
// site from the result.
|
|
Assert.Single(calls);
|
|
Assert.Equal("int total = Extract(limit);", calls[0].ToString());
|
|
|
|
VerifyZeroDiagnostics(tree);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Summarize full body (68..76): the selection ends the (non-void)
|
|
// enclosing method and the trailing `return message;` is nameable — the
|
|
// call site becomes `return Extract(limit);` and NO extra return is
|
|
// appended to the new body (the existing one is kept).
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void Summarize_full_body_selection_becomes_return_call()
|
|
{
|
|
var (method, calls, tree) = Generate(68, 76);
|
|
|
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
|
Assert.Equal("string", method.ReturnType.ToString());
|
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
|
|
|
var body = (BlockSyntax)method.Body!;
|
|
Assert.Equal(5, body.Statements.Count); // total, for, scaled, message, return — unchanged
|
|
Assert.Equal("return message;", body.Statements[^1].ToString());
|
|
|
|
Assert.Single(calls);
|
|
Assert.Equal("return Extract(limit);", calls[0].ToString());
|
|
|
|
VerifyZeroDiagnostics(tree);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Heaviest full body (87..93): the trailing `return best;` names the
|
|
// single return slot, which matches the enclosing method's return type —
|
|
// again a `return Extract(...)` call site, with the widgets/count
|
|
// parameters as plain in-params.
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void Heaviest_full_body_selection_becomes_return_call()
|
|
{
|
|
var (method, calls, tree) = Generate(87, 93);
|
|
|
|
Assert.Equal("Widget", method.ReturnType.ToString());
|
|
Assert.Equal(new[] { "int count", "List<Widget> widgets" },
|
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
|
|
|
var body = (BlockSyntax)method.Body!;
|
|
Assert.Equal(3, body.Statements.Count);
|
|
Assert.Equal("return best;", body.Statements[^1].ToString());
|
|
|
|
Assert.Single(calls);
|
|
Assert.Equal("return Extract(count, widgets);", calls[0].ToString());
|
|
|
|
VerifyZeroDiagnostics(tree);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// ScoreReads (56..58): the enclosing method returns int but the selection
|
|
// ends with the COMPOSITE `return score + bonus;` — not nameable in v1,
|
|
// so the promoted signature is void and codegen must REFUSE (the report
|
|
// note already says: extract a local first, then re-run).
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void ScoreReads_refuses_when_the_composite_trailing_return_is_not_nameable()
|
|
{
|
|
var (tree, compilation) = DemoFixture.Load();
|
|
var model = compilation.GetSemanticModel(tree);
|
|
|
|
var resolved = SelectionResolver.Resolve(tree, 56, 58);
|
|
Assert.True(resolved.Succeeded, resolved.Error);
|
|
|
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
|
Assert.Equal("void", signature.ReturnType);
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(
|
|
() => RefactoringGenerator.Generate(model, resolved, signature));
|
|
Assert.Contains("extract it into a local first", ex.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Accumulate for-loop (127..130): `acc` is declared BEFORE the selection,
|
|
// written inside and read after — the promoted signature keeps it as a
|
|
// ref parameter (the write-back flows the value out; void return, no
|
|
// return slot) and the call site is a plain call with `ref acc`.
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void Accumulate_forloop_selection_calls_with_ref_write_back()
|
|
{
|
|
var (method, calls, tree) = Generate(127, 130);
|
|
|
|
Assert.Equal("void", method.ReturnType.ToString());
|
|
Assert.Equal(new[] { "ref int acc", "int n" },
|
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
|
|
|
var body = (BlockSyntax)method.Body!;
|
|
Assert.Single(body.Statements); // the for-loop; no return appended (void)
|
|
|
|
Assert.Single(calls);
|
|
Assert.Equal("Extract(ref acc, n);", calls[0].ToString());
|
|
|
|
VerifyZeroDiagnostics(tree);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Pairwise (141..147): two declared-inside locals both written inside and
|
|
// read after — the promoted signature returns the tuple `(int, int)` and
|
|
// the call site deconstructs it; the new body gains `return (x, y);`.
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void Pairwise_selection_generates_tuple_return_and_deconstruction_call()
|
|
{
|
|
var (method, calls, tree) = Generate(141, 147);
|
|
|
|
Assert.Equal("(int, int)", method.ReturnType.ToString());
|
|
Assert.Equal(new[] { "int n" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
|
|
|
var body = (BlockSyntax)method.Body!;
|
|
Assert.Equal(4, body.Statements.Count); // x, y, for-loop, appended tuple return
|
|
Assert.Equal("return (x, y);", body.Statements[^1].ToString());
|
|
|
|
Assert.Single(calls);
|
|
Assert.Equal("var (x, y) = Extract(n);", calls[0].ToString());
|
|
|
|
VerifyZeroDiagnostics(tree);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// The unified diff, pinned exactly for a minimal edit (hunk header,
|
|
// context, deletion before insertion).
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void UnifiedDiff_pins_the_hunk_format()
|
|
{
|
|
var diff = UnifiedDiff.Diff(
|
|
"a\nb\nc\n", "a\nX\nc\nd\n", "Demo.cs", "Demo.cs (generated)");
|
|
|
|
Assert.Equal(
|
|
"--- Demo.cs\n" +
|
|
"+++ Demo.cs (generated)\n" +
|
|
"@@ -1,3 +1,4 @@\n" +
|
|
" a\n" +
|
|
"-b\n" +
|
|
"+X\n" +
|
|
" c\n" +
|
|
"+d\n",
|
|
diff);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Identical texts produce a header with no hunks.
|
|
// ---------------------------------------------------------------------
|
|
[Fact]
|
|
public void UnifiedDiff_of_identical_texts_is_header_only()
|
|
{
|
|
Assert.Equal("--- a\n+++ b\n", UnifiedDiff.Diff("same\n", "same\n", "a", "b"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
private static (MethodDeclarationSyntax Method, List<StatementSyntax> Calls, SyntaxTree Tree)
|
|
Generate(int startLine, int endLine)
|
|
{
|
|
var (tree, compilation) = DemoFixture.Load();
|
|
var model = compilation.GetSemanticModel(tree);
|
|
|
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
|
Assert.True(resolved.Succeeded, resolved.Error);
|
|
|
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
|
|
|
var refactoring = RefactoringGenerator.Generate(model, resolved, signature);
|
|
return (refactoring.NewMethod, refactoring.CallStatements.ToList(), refactoring.TransformedTree);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The yak's round-trip check: the transformed tree must compile with
|
|
/// ZERO diagnostics under the same scratch compilation the input file
|
|
/// compiles under.
|
|
/// </summary>
|
|
private static void VerifyZeroDiagnostics(SyntaxTree tree)
|
|
{
|
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
|
var diagnostics = compilation.GetDiagnostics().ToList();
|
|
Assert.True(diagnostics.Count == 0, string.Join("\n", diagnostics));
|
|
}
|
|
} |