diff --git a/.gitignore b/.gitignore index 363c897..782430f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ artifacts/ bin/ obj/ .dotnet-cli/ -*.user \ No newline at end of file +*.user +# NuGet restore scratch dir (DOTNET_CLI_HOME fallout) +.local/ diff --git a/db-subclass-to-dto.sln b/db-subclass-to-dto.sln index ed19c24..8d2c2b1 100644 --- a/db-subclass-to-dto.sln +++ b/db-subclass-to-dto.sln @@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x86.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +83,6 @@ Global {2FF256B4-B085-449E-95AD-B9E6202DA94A} = {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} + {3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} EndGlobalSection EndGlobal diff --git a/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj index b33b17e..68f1630 100644 --- a/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj +++ b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj @@ -21,6 +21,19 @@ + + + + + + + + \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/ExtractMethod/DemoFixtureTests.cs b/tests/BeforeAfter.Tests/ExtractMethod/DemoFixtureTests.cs new file mode 100644 index 0000000..d92fbe5 --- /dev/null +++ b/tests/BeforeAfter.Tests/ExtractMethod/DemoFixtureTests.cs @@ -0,0 +1,102 @@ +using ExtractMethod.Tooling; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace BeforeAfter.Tests.ExtractMethod; + +/// +/// 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 +/// so these tests exercise the exact loading path the CLI uses. +/// +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() + .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().FirstOrDefault()?.Identifier.ValueText; +} \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/ExtractMethod/Fixtures/Demo.cs b/tests/BeforeAfter.Tests/ExtractMethod/Fixtures/Demo.cs new file mode 100644 index 0000000..5d2711b --- /dev/null +++ b/tests/BeforeAfter.Tests/ExtractMethod/Fixtures/Demo.cs @@ -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; + +/// +/// Small first-class citizen of the extract-method tool: every method below +/// shows one or more of the classification "buckets" the tool must report. +/// +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; + + /// Small local type so the scratch compilation binds. + public sealed record Widget(int Weight, int Count, string Label) + { + /// Instance method used to exercise the invocation bucket. + public Widget Bigger(Widget other) => + Weight >= other.Weight ? this : other; + } + + /// + /// 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. + /// + public int ScoreReads(int bonus) + { + int seed = _seed; + int score = seed * 2; + return score + bonus; + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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). + /// + public Widget Heaviest(List widgets, int count) + { + Widget best = widgets[0]; + for (int i = 1; i < count; i++) + { + best = best.Bigger(widgets[i]); + } + + return best; + } +} \ No newline at end of file diff --git a/tools/ExtractMethod/ExtractMethod.csproj b/tools/ExtractMethod/ExtractMethod.csproj new file mode 100644 index 0000000..69722c6 --- /dev/null +++ b/tools/ExtractMethod/ExtractMethod.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + ExtractMethod + + + + + + + + \ No newline at end of file diff --git a/tools/ExtractMethod/Program.cs b/tools/ExtractMethod/Program.cs new file mode 100644 index 0000000..3a8d571 --- /dev/null +++ b/tools/ExtractMethod/Program.cs @@ -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 (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; \ No newline at end of file diff --git a/tools/ExtractMethod/Tooling/CompilationLoader.cs b/tools/ExtractMethod/Tooling/CompilationLoader.cs new file mode 100644 index 0000000..9ed690b --- /dev/null +++ b/tools/ExtractMethod/Tooling/CompilationLoader.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ExtractMethod.Tooling; + +/// +/// Turns a plain .cs file into a Roslyn 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. +/// +public static class CompilationLoader +{ + /// + /// Parse a file into a 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. + /// + 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)); + } + + /// + /// Build a scratch compilation over . + /// + /// WHY TRUSTED_PLATFORM_ASSEMBLIES: 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.) + /// + 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)); + } +} \ No newline at end of file diff --git a/tools/ExtractMethod/Tooling/SelectionResolver.cs b/tools/ExtractMethod/Tooling/SelectionResolver.cs new file mode 100644 index 0000000..856e13b --- /dev/null +++ b/tools/ExtractMethod/Tooling/SelectionResolver.cs @@ -0,0 +1,168 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ExtractMethod.Tooling; + +/// A line range resolved to the whole statements it covers. +public sealed record SelectionReport +{ + public required bool Succeeded { get; init; } + + /// Human-readable reason when is false. + public string? Error { get; init; } + + /// + /// The selected whole statements, in source order, all siblings in one + /// statement list (v1: the enclosing method's body block). + /// + public required IReadOnlyList Statements { get; init; } + + /// The method that owns the selected statements. + public required MethodDeclarationSyntax? Method { get; init; } + + public int StartLine { get; init; } + public int EndLine { get; init; } + + public int Count => Statements.Count; + + /// Statement kinds (e.g. LocalDeclarationStatement, ForStatement). + public IEnumerable Kinds => Statements.Select(s => s.Kind()); +} + +/// +/// 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). +/// +public static class SelectionResolver +{ + /// Line range of a missing selection (error path). + private static readonly SelectionReport Failure = new() + { + Succeeded = false, + Statements = Array.Empty(), + Method = null, + }; + + /// Common ExitCode for a clean resolution error (see also 2 = usage). + 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() + .FirstOrDefault(); + + var method = enclosingStatement?.AncestorsAndSelf() + .OfType() + .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); + } +} \ No newline at end of file