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