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.
This commit is contained in:
2026-09-12 15:28:14 +01:00
parent 406e8a97f6
commit b012d0aaa0
9 changed files with 526 additions and 1 deletions
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ExtractMethod</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!--
Roslyn IS the tool: we parse C# source, build a scratch compilation and
ask the semantic model for data flow. Pinned to the latest stable on
nuget.org (5.9.0, Roslyn's versioning now tracks the VS release train).
-->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
</ItemGroup>
</Project>
+51
View File
@@ -0,0 +1,51 @@
using ExtractMethod.Tooling;
// CLIs are boring on purpose: argument parsing and printing live here, all
// Roslyn logic lives in Tooling/ so the tests can drive it directly.
string usage = "usage: ExtractMethod <file.cs> <startLine> <endLine> (1-based, inclusive)";
if (args.Length != 3)
{
Console.Error.WriteLine(usage);
return 2;
}
string file = Path.GetFullPath(args[0]);
if (!File.Exists(file))
{
Console.Error.WriteLine($"error: file not found: {file}");
Console.Error.WriteLine(usage);
return 2;
}
if (!int.TryParse(args[1], out int startLine) || !int.TryParse(args[2], out int endLine))
{
Console.Error.WriteLine($"error: line numbers must be integers");
Console.Error.WriteLine(usage);
return 2;
}
// 1. parse the file
var tree = CompilationLoader.ParseFile(file);
// 2. build the scratch compilation (refs from TRUSTED_PLATFORM_ASSEMBLIES)
var compilation = CompilationLoader.CreateCompilation(tree, Path.GetFileNameWithoutExtension(file));
if (compilation.GetDiagnostics().Any(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error))
{
Console.Error.WriteLine("warning: the file does not compile cleanly under a plain Roslyn compilation; reporting syntax-level resolution only");
}
// 3. snap the range to whole statements, report cleanly otherwise
var report = SelectionResolver.Resolve(tree, startLine, endLine);
if (!report.Succeeded)
{
Console.Error.WriteLine($"error: {report.Error}");
return SelectionResolver.ExitError;
}
Console.WriteLine(
$"{report.Count} statement(s) selected, lines {report.StartLine}..{report.EndLine} " +
$"in {report.Method?.Identifier.ValueText}(): " +
string.Join(", ", report.Kinds));
return 0;
@@ -0,0 +1,57 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace ExtractMethod.Tooling;
/// <summary>
/// Turns a plain .cs file into a Roslyn <see cref="CSharpCompilation"/> that a
/// semantic model can be queried against.
///
/// Shared by the CLI (Program.cs) and the tests so both exercise the same
/// loading path — the tests should not re-implement this, or they would be
/// testing their own assumptions instead of the tool.
/// </summary>
public static class CompilationLoader
{
/// <summary>
/// Parse a file into a <see cref="SyntaxTree"/> using the latest language
/// version. Nothing here is project-specific: no .sln, no .csproj, no
/// generated files — the tool is a micro-tool that runs on a single file.
/// </summary>
public static SyntaxTree ParseFile(string path)
{
var text = File.ReadAllText(Path.GetFullPath(path));
return CSharpSyntaxTree.ParseText(
text,
path: path,
options: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest));
}
/// <summary>
/// Build a scratch compilation over <paramref name="tree"/>.
///
/// WHY <c>TRUSTED_PLATFORM_ASSEMBLIES</c>: it is the runtime-resolved list
/// of the actual BCL assemblies this process (net10.0) runs on, e.g.
/// System.Private.CoreLib, System.Runtime, System.Console. Referencing
/// them means an arbitrary fixture can bind List&lt;T&gt;, string, and the
/// rest of the BCL without any NuGet/EF/SQLite haul. (A single
/// typeof(object) reference is NOT enough on modern .NET — that only pulls
/// in the core library, and Console/GC/enums etc. fail to bind.)
/// </summary>
public static CSharpCompilation CreateCompilation(SyntaxTree tree, string assemblyName)
{
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p));
return CSharpCompilation.Create(
assemblyName,
new[] { tree },
refs,
new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
// Match the repo's net10.0 projects so the fixture's nullable
// annotations compile without warnings.
nullableContextOptions: NullableContextOptions.Enable));
}
}
@@ -0,0 +1,168 @@
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);
}
}