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