diff --git a/src/LanguageServer/Protocol/Handler/FoldingRanges/FoldingRangesHandler.cs b/src/LanguageServer/Protocol/Handler/FoldingRanges/FoldingRangesHandler.cs index d58ddd991b575..946363b844c3f 100644 --- a/src/LanguageServer/Protocol/Handler/FoldingRanges/FoldingRangesHandler.cs +++ b/src/LanguageServer/Protocol/Handler/FoldingRanges/FoldingRangesHandler.cs @@ -42,12 +42,14 @@ public FoldingRangesHandler(IGlobalOptionService globalOptions) if (document is null) return SpecializedTasks.Null(); - return SpecializedTasks.AsNullable(GetFoldingRangesAsync(_globalOptions, document, cancellationToken)); + var lineFoldingOnly = context.GetRequiredClientCapabilities().TextDocument?.FoldingRange?.LineFoldingOnly == true; + return SpecializedTasks.AsNullable(GetFoldingRangesAsync(_globalOptions, document, lineFoldingOnly, cancellationToken)); } internal static Task GetFoldingRangesAsync( IGlobalOptionService globalOptions, Document document, + bool lineFoldingOnly, CancellationToken cancellationToken) { var options = globalOptions.GetBlockStructureOptions(document.Project) with @@ -59,7 +61,7 @@ internal static Task GetFoldingRangesAsync( ShowBlockStructureGuidesForCodeLevelConstructs = true }; - return GetFoldingRangesAsync(document, options, cancellationToken); + return GetFoldingRangesAsync(document, options, lineFoldingOnly, cancellationToken); } /// @@ -68,6 +70,7 @@ internal static Task GetFoldingRangesAsync( public static async Task GetFoldingRangesAsync( Document document, BlockStructureOptions options, + bool lineFoldingOnly, CancellationToken cancellationToken) { var blockStructureService = document.GetRequiredLanguageService(); @@ -76,10 +79,10 @@ public static async Task GetFoldingRangesAsync( return []; var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false); - return GetFoldingRanges(blockStructure, text); + return GetFoldingRanges(blockStructure, text, lineFoldingOnly); } - private static FoldingRange[] GetFoldingRanges(BlockStructure blockStructure, SourceText text) + private static FoldingRange[] GetFoldingRanges(BlockStructure blockStructure, SourceText text, bool lineFoldingOnly) { if (blockStructure.Spans.IsEmpty) { @@ -103,8 +106,6 @@ private static FoldingRange[] GetFoldingRanges(BlockStructure blockStructure, So continue; } - // TODO - Figure out which blocks should be returned as a folding range (and what kind). - // https://github.com/dotnet/roslyn/projects/45#card-20049168 FoldingRangeKind? foldingRangeKind = span.Type switch { BlockTypes.Comment => FoldingRangeKind.Comment, @@ -123,8 +124,66 @@ private static FoldingRange[] GetFoldingRanges(BlockStructure blockStructure, So Kind = foldingRangeKind, CollapsedText = span.BannerText }); + + if (lineFoldingOnly) + { + foldingRanges = AdjustToEnsureNonOverlappingLines(foldingRanges); + } } return foldingRanges.ToArray(); + + static ArrayBuilder AdjustToEnsureNonOverlappingLines(ArrayBuilder foldingRanges) + { + using var _ = PooledDictionary.GetInstance(out var startLineToFoldingRange); + + // Spans are sorted in descending order by start position (the span starting closer to the end of the file is first). + foreach (var foldingRange in foldingRanges) + { + var updatedRange = foldingRange; + // Check if another span starts on the same line. + if (startLineToFoldingRange.ContainsKey(foldingRange.StartLine)) + { + // There's already a span that starts on this line. We want to keep the innermost span, which is the one + // we already have in the dictionary (as it started later in the file). Skip this one. + continue; + } + + var endLine = foldingRange.EndLine; + + // Check if this span ends on the same line another span starts. + // Since we're iterating bottom up, if there is a span that starts on this end line, it will be in the dictionary. + if (startLineToFoldingRange.ContainsKey(endLine)) + { + // The end line of this span overlaps with the start line of another span - attempt to adjust this one + // to the prior line. + var adjustedEndLine = endLine - 1; + + // If the adjusted end line is now at or before the start line, there's no folding range possible without line overlapping another span. + if (adjustedEndLine <= foldingRange.StartLine) + { + continue; + } + + updatedRange = new FoldingRange + { + StartLine = foldingRange.StartLine, + StartCharacter = foldingRange.StartCharacter, + EndLine = adjustedEndLine, + EndCharacter = foldingRange.EndCharacter, + Kind = foldingRange.Kind, + CollapsedText = foldingRange.CollapsedText + }; + } + + // These are explicitly ignored by the client when lineFoldingOnly is true, so no need to serialize them. + updatedRange.StartCharacter = null; + updatedRange.EndCharacter = null; + + startLineToFoldingRange[foldingRange.StartLine] = updatedRange; + } + + return [.. startLineToFoldingRange.Values]; + } } } diff --git a/src/LanguageServer/ProtocolUnitTests/FoldingRanges/FoldingRangesTests.cs b/src/LanguageServer/ProtocolUnitTests/FoldingRanges/FoldingRangesTests.cs index 78e7aa8133de7..efb15f1791fb0 100644 --- a/src/LanguageServer/ProtocolUnitTests/FoldingRanges/FoldingRangesTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/FoldingRanges/FoldingRangesTests.cs @@ -4,6 +4,7 @@ #nullable disable +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -64,11 +65,90 @@ private Action Foo(){|implementation: => i =>{|foldingRange: }|} """); - private async Task AssertFoldingRanges(bool mutatingLspWorkspace, string markup, string collapsedText = null) + [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/7974")] + public Task TestGetFoldingRangeAsync_LineFoldingOnly_NoOverlappingRanges(bool mutatingLspWorkspace) + => AssertFoldingRanges(mutatingLspWorkspace, """ + class C{|foldingRange: + { + public void M1(){|implementation: + { + var x = 1; + }|} + public void M2(){|implementation: + { + var y = 2; + }|} + }|} + """, lineFoldingOnly: true); + + [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/7974")] + public Task TestGetFoldingRangeAsync_LineFoldingOnly_StartLineOverlapsChoosesInner(bool mutatingLspWorkspace) + => AssertFoldingRanges(mutatingLspWorkspace, """ + class C { public void M1() {|implementation:{ + var x = 1; + }|} + } + """, lineFoldingOnly: true); + + [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/7974")] + public Task TestGetFoldingRangeAsync_LineFoldingOnly_EndLineOverlaps(bool mutatingLspWorkspace) + => AssertFoldingRanges(mutatingLspWorkspace, """ + class C{|foldingRange: + { + void M(){|implementation: + { + void Local(){|foldingRange: + { + }|}}|}}|} + """, lineFoldingOnly: true); + + [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/7974")] + public Task TestGetFoldingRangeAsync_LineFoldingOnly_EndLineOverlapsStartLine(bool mutatingLspWorkspace) + => AssertFoldingRanges(mutatingLspWorkspace, """ + class C{|foldingRange: + { + void M(){|implementation: + { + if (true){|foldingRange: + {|} + } else{|foldingRange: { + }|} + }|} + }|} + """, lineFoldingOnly: true); + + [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/7974")] + public Task TestGetFoldingRangeAsync_WithoutLineFoldingOnly_AllowsRangesOnSameLine(bool mutatingLspWorkspace) + => AssertFoldingRanges(mutatingLspWorkspace, """ + class C {|foldingRange:{ public void M1() {|implementation:{ + if (true){|foldingRange: + { + }|} else{|foldingRange: { + }|} + }|} + }|} + """, lineFoldingOnly: false); + + private async Task AssertFoldingRanges( + bool mutatingLspWorkspace, + [StringSyntax(PredefinedEmbeddedLanguageNames.CSharpTest)] string markup, + string collapsedText = null, + bool lineFoldingOnly = false) { - var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); + var clientCapabilities = new LSP.ClientCapabilities + { + TextDocument = new LSP.TextDocumentClientCapabilities + { + FoldingRange = new LSP.FoldingRangeSetting + { + LineFoldingOnly = lineFoldingOnly + } + } + }; + + var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, clientCapabilities); var expected = testLspServer.GetLocations() - .SelectMany(kvp => kvp.Value.Select(location => CreateFoldingRange(kvp.Key, location.Range, collapsedText ?? "..."))) + .SelectMany(kvp => kvp.Value.Select(location => CreateFoldingRange(kvp.Key, location.Range, collapsedText ?? "...", lineFoldingOnly))) .OrderByDescending(range => range.StartLine) .ThenByDescending(range => range.StartCharacter) .ToArray(); @@ -89,7 +169,7 @@ private async Task AssertFoldingRanges(bool mutatingLspWorkspace, string markup, request, CancellationToken.None); } - private static LSP.FoldingRange CreateFoldingRange(string kind, LSP.Range range, string collapsedText) + private static LSP.FoldingRange CreateFoldingRange(string kind, LSP.Range range, string collapsedText, bool lineFoldingOnly) => new() { Kind = kind switch @@ -98,8 +178,8 @@ private static LSP.FoldingRange CreateFoldingRange(string kind, LSP.Range range, null => null, _ => new(kind) }, - StartCharacter = range.Start.Character, - EndCharacter = range.End.Character, + StartCharacter = lineFoldingOnly ? null : range.Start.Character, + EndCharacter = lineFoldingOnly ? null : range.End.Character, StartLine = range.Start.Line, EndLine = range.End.Line, CollapsedText = collapsedText diff --git a/src/Tools/ExternalAccess/Razor/Features/Cohost/Handlers/FoldingRanges.cs b/src/Tools/ExternalAccess/Razor/Features/Cohost/Handlers/FoldingRanges.cs index 2f95ee700b3d6..4d2fd6ebe4869 100644 --- a/src/Tools/ExternalAccess/Razor/Features/Cohost/Handlers/FoldingRanges.cs +++ b/src/Tools/ExternalAccess/Razor/Features/Cohost/Handlers/FoldingRanges.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.LanguageServer.Handler; @@ -12,12 +13,18 @@ namespace Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; internal static class FoldingRanges { + [Obsolete("Use GetFoldingRangesAsync(Document, bool, CancellationToken) instead", error: true)] public static Task GetFoldingRangesAsync(Document document, CancellationToken cancellationToken) + { + return GetFoldingRangesAsync(document, lineFoldingOnly: false, cancellationToken); + } + + public static Task GetFoldingRangesAsync(Document document, bool lineFoldingOnly, CancellationToken cancellationToken) { // We need to manually get the IGlobalOptionsService out of the Mef composition, because Razor has its own // composition so can't import it (and its internal anyway) var globalOptions = document.Project.Solution.Services.ExportProvider.GetService(); - return FoldingRangesHandler.GetFoldingRangesAsync(globalOptions, document, cancellationToken); + return FoldingRangesHandler.GetFoldingRangesAsync(globalOptions, document, lineFoldingOnly: lineFoldingOnly, cancellationToken); } }