- tools/ExtractMethod: net10.0 console app (Microsoft.CodeAnalysis.CSharp 5.9.0, pinned latest stable). CLI: <file.cs> <startLine> <endLine>. Parses the file, builds a scratch compilation with refs from TRUSTED_PLATFORM_ASSEMBLIES, and reports how many whole statements the line range covers (clean error otherwise; exit codes: 0 ok, 1 resolution error, 2 usage). - Tooling/CompilationLoader: shared parse + compilation path for CLI and tests (tests exercise the exact loading path the CLI uses). - Tooling/SelectionResolver: snaps a 1-based inclusive line range to whole statements in the enclosing method body block; boundary checks never split a statement; nested/blank-line ranges handled cleanly. - tests/: ExtractMethod/Fixtures/Demo.cs checked-in fixture exercising every bucket of the parent spec (read-only local, written+read-later return, scratch local, param read, field+property access, indexer + method invocation, multi-statement range incl. a for-loop); excluded from project compilation, copied to output as data. - Tests: Demo.cs compiles with no diagnostics; range 68..72 resolves to 2 statements (LocalDeclarationStatement, ForStatement); AnalyzeDataFlow succeeds on the fixture's for-loop node; mid-statement range fails cleanly. 30/30 green.
168 lines
6.8 KiB
C#
168 lines
6.8 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
|
|
namespace ExtractMethod.Tooling;
|
|
|
|
/// <summary>A line range resolved to the whole statements it covers.</summary>
|
|
public sealed record SelectionReport
|
|
{
|
|
public required bool Succeeded { get; init; }
|
|
|
|
/// <summary>Human-readable reason when <see cref="Succeeded"/> is false.</summary>
|
|
public string? Error { get; init; }
|
|
|
|
/// <summary>
|
|
/// The selected whole statements, in source order, all siblings in one
|
|
/// statement list (v1: the enclosing method's body block).
|
|
/// </summary>
|
|
public required IReadOnlyList<StatementSyntax> Statements { get; init; }
|
|
|
|
/// <summary>The method that owns the selected statements.</summary>
|
|
public required MethodDeclarationSyntax? Method { get; init; }
|
|
|
|
public int StartLine { get; init; }
|
|
public int EndLine { get; init; }
|
|
|
|
public int Count => Statements.Count;
|
|
|
|
/// <summary>Statement kinds (e.g. LocalDeclarationStatement, ForStatement).</summary>
|
|
public IEnumerable<SyntaxKind> Kinds => Statements.Select(s => s.Kind());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snap a 1-based, inclusive line range (IDE-selection style) to a contiguous
|
|
/// set of WHOLE statements — never split a statement (parent spec decision #1).
|
|
///
|
|
/// v1 rule: the selection is matched against the top-level statement list of
|
|
/// the enclosing method's body block. A range nested inside a block whose
|
|
/// siblings live deeper (e.g. the body of a for-loop) is a clean error: such
|
|
/// selections would not survive extraction anyway, so the tool tells the user
|
|
/// to widen the selection to the whole enclosing statement (the loop).
|
|
/// </summary>
|
|
public static class SelectionResolver
|
|
{
|
|
/// <summary>Line range of a missing selection (error path).</summary>
|
|
private static readonly SelectionReport Failure = new()
|
|
{
|
|
Succeeded = false,
|
|
Statements = Array.Empty<StatementSyntax>(),
|
|
Method = null,
|
|
};
|
|
|
|
/// <summary>Common ExitCode for a clean resolution error (see also 2 = usage).</summary>
|
|
public const int ExitError = 1;
|
|
|
|
public static SelectionReport Resolve(SyntaxTree tree, int startLine, int endLine)
|
|
{
|
|
var text = tree.GetText();
|
|
|
|
// ---- validate and normalize the requested line range ----
|
|
if (startLine < 1 || startLine > text.Lines.Count)
|
|
{
|
|
return Fail($"startLine {startLine} is out of range (file has {text.Lines.Count} lines)");
|
|
}
|
|
|
|
if (endLine < startLine || endLine > text.Lines.Count)
|
|
{
|
|
return Fail($"endLine {endLine} is out of range (file has {text.Lines.Count} lines)");
|
|
}
|
|
|
|
// Snap the endpoints past whitespace-only lines: an IDE "select lines"
|
|
// range often trails a blank line; blank lines are not statements, so
|
|
// skipping them is snapping, not splitting.
|
|
while (endLine > startLine && IsBlank(text.Lines[endLine - 1])) endLine--;
|
|
while (startLine < endLine && IsBlank(text.Lines[startLine - 1])) startLine++;
|
|
|
|
// Positions of the first / last non-whitespace character of the
|
|
// bounding lines. Trailing content (e.g. a `// comment` after the last
|
|
// statement) intentionally KEEPS the end position past the statement,
|
|
// which the boundary check below reports as "range ends mid-statement".
|
|
int selStart = FirstNonWhitespacePosition(text.Lines[startLine - 1]);
|
|
int selEnd = LastNonWhitespacePosition(text.Lines[endLine - 1]);
|
|
|
|
// ---- find the enclosing method ----
|
|
// Anchor on the first token of the selection: at statement start the
|
|
// token belongs to the statement we want (FindNode at exact boundaries
|
|
// is ambiguous, FindToken is not).
|
|
var firstToken = tree.GetRoot().FindToken(selStart);
|
|
var enclosingStatement = firstToken.Parent?.AncestorsAndSelf()
|
|
.OfType<StatementSyntax>()
|
|
.FirstOrDefault();
|
|
|
|
var method = enclosingStatement?.AncestorsAndSelf()
|
|
.OfType<MethodDeclarationSyntax>()
|
|
.FirstOrDefault();
|
|
|
|
if (method is null)
|
|
{
|
|
return Fail($"{startLine} does not sit inside a method (local functions and expression bodies are not supported in v1)");
|
|
}
|
|
|
|
// The whole selection must live inside ONE method body.
|
|
if (method.Body is null || selEnd > method.Body.FullSpan.End)
|
|
{
|
|
return Fail($"range {startLine}..{endLine} crosses a method boundary (selection must stay inside one method body)");
|
|
}
|
|
|
|
// ---- v1: only the method body's own statement list is selectable ----
|
|
if (enclosingStatement is not null && enclosingStatement.Parent != method.Body)
|
|
{
|
|
return Fail(
|
|
$"range {startLine}..{endLine} is nested inside \"{enclosingStatement.Kind()}\" — " +
|
|
"select the whole enclosing statement (e.g. the entire for-loop) instead");
|
|
}
|
|
|
|
// ---- collect the whole statements covered by the range ----
|
|
var body = method.Body;
|
|
var selected = body.Statements
|
|
.Where(s => selStart <= s.SpanStart && s.Span.End <= selEnd)
|
|
.ToList();
|
|
|
|
if (selected.Count == 0)
|
|
{
|
|
return Fail($"range {startLine}..{endLine} covers no whole top-level statement (mid-statement or empty range)");
|
|
}
|
|
|
|
// Boundary checks: the selection must START at a statement's first
|
|
// token and END right after a statement's last token. Siblings never
|
|
// overlap, so equal boundaries also imply the selected run is
|
|
// contiguous (any statement between first and last lies inside the
|
|
// range and is therefore in `selected`).
|
|
if (selected[0].SpanStart != selStart)
|
|
{
|
|
return Fail($"range {startLine}..{endLine} starts inside a statement — snap to whole statements");
|
|
}
|
|
|
|
if (selected[^1].Span.End != selEnd)
|
|
{
|
|
return Fail($"range {startLine}..{endLine} ends inside a statement (or picks up trailing text on the last line) — snap to whole statements");
|
|
}
|
|
|
|
return new SelectionReport
|
|
{
|
|
Succeeded = true,
|
|
Statements = selected,
|
|
Method = method,
|
|
StartLine = startLine,
|
|
EndLine = endLine,
|
|
};
|
|
}
|
|
|
|
private static SelectionReport Fail(string reason) => Failure with { Error = reason };
|
|
|
|
private static bool IsBlank(TextLine line) => string.IsNullOrWhiteSpace(line.ToString());
|
|
|
|
private static int FirstNonWhitespacePosition(TextLine line)
|
|
{
|
|
var s = line.ToString();
|
|
return line.Start + (s.Length - s.TrimStart().Length);
|
|
}
|
|
|
|
private static int LastNonWhitespacePosition(TextLine line)
|
|
{
|
|
var s = line.ToString();
|
|
return line.End - (s.Length - s.TrimEnd().Length);
|
|
}
|
|
} |