Files
db-subclass-to-dto/tools/ExtractMethod/Tooling/SelectionResolver.cs
T
mostalive b012d0aaa0 em 01: scaffold ExtractMethod tool + Demo fixture
- 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.
2026-09-12 15:28:14 +01:00

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