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:
@@ -4,3 +4,5 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
.dotnet-cli/
|
.dotnet-cli/
|
||||||
*.user
|
*.user
|
||||||
|
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||||
|
.local/
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -59,6 +63,18 @@ Global
|
|||||||
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -67,5 +83,6 @@ Global
|
|||||||
{2FF256B4-B085-449E-95AD-B9E6202DA94A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -21,6 +21,19 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Before\Before.csproj" />
|
<ProjectReference Include="..\..\src\Before\Before.csproj" />
|
||||||
<ProjectReference Include="..\..\src\After\After.csproj" />
|
<ProjectReference Include="..\..\src\After\After.csproj" />
|
||||||
|
<ProjectReference Include="..\..\tools\ExtractMethod\ExtractMethod.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The ExtractMethod fixtures are DATA for the Roslyn tool, not source:
|
||||||
|
they are excluded from this project's compilation (the scratch
|
||||||
|
compilation gives the tool the exact checked-in text) and copied to
|
||||||
|
the output dir so tests can find them by path. -->
|
||||||
|
<Compile Remove="ExtractMethod\Fixtures\**\*.cs" />
|
||||||
|
<!-- Rebase the copy target to Fixtures/ in the output dir: the tool's
|
||||||
|
apphost (the Executable 'ExtractMethod') is copied here too, and a
|
||||||
|
folder named ExtractMethod/ would collide with that file. -->
|
||||||
|
<Content Include="ExtractMethod\Fixtures\**\*.cs" Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 01: the scaffold (console tool) and the checked-in demo
|
||||||
|
/// fixture. The fixture is DATA (excluded from compilation, copied to the
|
||||||
|
/// output dir), and it is parsed with the tool's own <see cref="CompilationLoader"/>
|
||||||
|
/// so these tests exercise the exact loading path the CLI uses.
|
||||||
|
/// </summary>
|
||||||
|
public class DemoFixtureTests
|
||||||
|
{
|
||||||
|
private static readonly string FixturePath =
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "Fixtures", "Demo.cs");
|
||||||
|
|
||||||
|
private static (SyntaxTree Tree, CSharpCompilation Compilation) LoadFixture()
|
||||||
|
{
|
||||||
|
Assert.True(File.Exists(FixturePath), $"fixture missing at {FixturePath}");
|
||||||
|
var tree = CompilationLoader.ParseFile(FixturePath);
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
||||||
|
return (tree, compilation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (a) The fixture is pristine under the scratch compilation: if this
|
||||||
|
// fails, the fixture (or the TRUSTED_PLATFORM_ASSEMBLIES reference
|
||||||
|
// loading) is broken and every later yak would analyze garbage.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Demo_compiles_with_no_diagnostics()
|
||||||
|
{
|
||||||
|
var (_, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
Assert.Empty(compilation.GetDiagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (b) A known line range snaps to the expected whole statements.
|
||||||
|
// Lines 68..72 of Demo.cs are `int total = 0;` followed by the whole
|
||||||
|
// for-loop (for keyword .. closing brace) inside Summarize — exactly
|
||||||
|
// the "multi-statement range incl. a for-loop" shape of the spec.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Known_range_resolves_to_expected_statements()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||||
|
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
Assert.Equal(2, report.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { SyntaxKind.LocalDeclarationStatement, SyntaxKind.ForStatement },
|
||||||
|
report.Kinds);
|
||||||
|
Assert.Equal("Summarize", report.Method?.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (c) The semantic model's data-flow analysis works on the fixture's
|
||||||
|
// for-loop node — the foundation every classification in yak 02
|
||||||
|
// builds on. (AnalyzeDataFlow on the loop itself walks the bound loop
|
||||||
|
// body; Succeeded == false would mean the scratch compilation or the
|
||||||
|
// selected node cannot be bound.)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Fixture_for_loop_supports_data_flow_analysis()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
var forStatement = tree.GetRoot()
|
||||||
|
.DescendantNodes()
|
||||||
|
.OfType<ForStatementSyntax>()
|
||||||
|
.Single(n => EnclosingMethodName(n) == "Summarize");
|
||||||
|
|
||||||
|
var dataFlow = compilation.GetSemanticModel(tree).AnalyzeDataFlow(forStatement);
|
||||||
|
|
||||||
|
Assert.True(dataFlow.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Acceptance also demands "clean error otherwise": selecting only the
|
||||||
|
// for-loop header line (line 69) splits the statement and must fail with
|
||||||
|
// a readable reason instead of a crash or a silent wrong count.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Range_ending_mid_statement_fails_cleanly()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 69, 69);
|
||||||
|
|
||||||
|
Assert.False(report.Succeeded);
|
||||||
|
Assert.NotNull(report.Error);
|
||||||
|
Assert.Contains("no whole", report.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EnclosingMethodName(SyntaxNode node) =>
|
||||||
|
node.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().FirstOrDefault()?.Identifier.ValueText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Demo fixture for the extract-method micro-tool (tools/ExtractMethod).
|
||||||
|
//
|
||||||
|
// This file is DATA, not project source: the test project excludes it from
|
||||||
|
// compilation (see BeforeAfter.Tests.csproj) and runs Roslyn over its raw
|
||||||
|
// text, so the tool sees exactly what is checked in here. It uses explicit
|
||||||
|
// usings because the scratch compilation has no implicit usings.
|
||||||
|
//
|
||||||
|
// Bucket map (the classification yak 02 works from):
|
||||||
|
// bucket where example
|
||||||
|
// local, read-only (param) ScoreReads seed, score
|
||||||
|
// written + read-later (return) Summarize, Heaviest total, message, best
|
||||||
|
// scratch local (local) Summarize for-loop i
|
||||||
|
// enclosing-method param (param) ScoreReads, Summarize bonus, limit
|
||||||
|
// field access (extract-first) ScoreReads _seed (optional)
|
||||||
|
// property access (extract-first) Summarize Scale (optional)
|
||||||
|
// indexer access (extract-first) Heaviest widgets[i]
|
||||||
|
// method invocation (extract-first, flagged) Heaviest best.Bigger(...)
|
||||||
|
// multi-statement range incl. for Summarize total + for-loop
|
||||||
|
//
|
||||||
|
// Tests pin EXACT line numbers of the statements they select. Keep the
|
||||||
|
// formatting stable; when a line must change, update DemoFixtureTests too.
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod.Fixtures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small first-class citizen of the extract-method tool: every method below
|
||||||
|
/// shows one or more of the classification "buckets" the tool must report.
|
||||||
|
/// </summary>
|
||||||
|
public class Demo
|
||||||
|
{
|
||||||
|
// Own-class state: reading it from a selection is "extract-first optional"
|
||||||
|
// (a same-class extracted method can still see these fields). Initialized
|
||||||
|
// here so the fixture compiles with zero diagnostics (CS0649 otherwise).
|
||||||
|
private int _seed = 5;
|
||||||
|
|
||||||
|
// Own-class property: like a field, accessible from a same-class method,
|
||||||
|
// so hoisting it is optional in the report.
|
||||||
|
public int Scale { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Small local type so the scratch compilation binds.</summary>
|
||||||
|
public sealed record Widget(int Weight, int Count, string Label)
|
||||||
|
{
|
||||||
|
/// <summary>Instance method used to exercise the invocation bucket.</summary>
|
||||||
|
public Widget Bigger(Widget other) =>
|
||||||
|
Weight >= other.Weight ? this : other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: enclosing-method parameter read (bonus => param), local
|
||||||
|
/// read-only (seed, score => param), own-class field access
|
||||||
|
/// (_seed => extract-first, optional). Ends with a return expression.
|
||||||
|
/// </summary>
|
||||||
|
public int ScoreReads(int bonus)
|
||||||
|
{
|
||||||
|
int seed = _seed;
|
||||||
|
int score = seed * 2;
|
||||||
|
return score + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: written + read-later locals (total, message => return),
|
||||||
|
/// scratch local (i => local), own-class property access (Scale =>
|
||||||
|
/// extract-first optional), multi-statement range incl. a for-loop.
|
||||||
|
/// </summary>
|
||||||
|
public string Summarize(int limit)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < limit; i++)
|
||||||
|
{
|
||||||
|
total += i;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scaled = total * Scale;
|
||||||
|
string message = "sum=" + scaled;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: indexer access (widgets[i] on List<T> => extract-first),
|
||||||
|
/// method invocation (Bigger => extract-first, flagged because hoisting
|
||||||
|
/// changes how often it is evaluated), local written + read-later
|
||||||
|
/// (best => return).
|
||||||
|
/// </summary>
|
||||||
|
public Widget Heaviest(List<Widget> widgets, int count)
|
||||||
|
{
|
||||||
|
Widget best = widgets[0];
|
||||||
|
for (int i = 1; i < count; i++)
|
||||||
|
{
|
||||||
|
best = best.Bigger(widgets[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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<T>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user