diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 27f2c0070d7..e962cd020dd 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -196,6 +196,7 @@ * Constraint solver: `TryD` is now `inline` with `[]` on its always-run continuation, so the argument closures are no longer allocated at the (very hot) constraint-solver call sites; `IgnoreFailedMemberConstraintResolution` is `inline` so its forwarded continuation stays a literal. ([PR #20367](https://github.com/dotnet/fsharp/pull/20367)) * `DelayedILModuleReader` no longer boxes its cached `ILModuleReader` on every read: the field is typed `ILModuleReader | null` and matched directly. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) * Optimizer: passing a partial application of a non-inline module-level function to an `[]` parameter (e.g. `xs |> Option.map (f a b)`) no longer allocates a per-call `FSharpFunc` closure when a captured argument is non-trivial (a field read, a call). Under optimization the argument is eta-expanded to a lambda with its captured evaluations floated above the binding, so the parameter's uses beta-reduce and the closure is eliminated. Captured arguments are still evaluated exactly once, in their original left-to-right order, and the binding keeps its sequence point. Partial applications of inline/SRTP functions and curried members can still allocate closures. ([PR #20487](https://github.com/dotnet/fsharp/pull/20487)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` scans the source lines without building a trimmed string per comment line, and no longer stores the text of a comment group next to its line numbers. ([PR #20443](https://github.com/dotnet/fsharp/pull/20443)) ### Changed * The `--warnaserror` option now ignores unrecognized diagnostic identifiers in warning lists while still applying recognized F# warning codes. ([PR #20246](https://github.com/dotnet/fsharp/pull/20246)) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..ba6b4d543c1 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -22,3 +22,4 @@ * Unused analyzers: disable in VS when file has errors ([PR #19892](https://github.com/dotnet/fsharp/pull/19892)) * Move to Roslyn's unified ExternalAccess library ([PR #20099](https://github.com/dotnet/fsharp/pull/20099)) * Remove trailing whitespace from source files. No functional change: whitespace inside string literals and inactive `#if` regions is preserved. ([PR #20355](https://github.com/dotnet/fsharp/pull/20355)) +* Reduce editor allocations when computing block structure (code folding) by slicing the source text once per outlining pass instead of copying every line. ([PR #20443](https://github.com/dotnet/fsharp/pull/20443)) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..e40ddda9e73 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -186,27 +187,38 @@ module Structure = } type LineNumber = int - type LineStr = string type CommentType = | SingleLine | XmlDoc + /// + /// Determines if a line is a single line or xml documentation comment. + /// + /// + /// Kept at module scope: a local recursive function capturing a -typed + /// helper as a closure field would need to instantiate + /// over it, which the CLR disallows for byref-like type arguments (FS0412). + /// + let commentTypeOf (line: ReadOnlySpan) = + if line.StartsWithOrdinal("///") then ValueSome XmlDoc + elif line.StartsWithOrdinal("//") then ValueSome SingleLine + else ValueNone + [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineStr = + static member New ty lineNum = { Type = ty - Lines = ResizeArray [ lineStr ] + Lines = ResizeArray [ lineNum ] } - /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + let getOutliningRangesFromLineSlices (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +673,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall System.String.IsNullOrWhiteSpace + |> Array.forall _.Span.IsWhiteSpace() -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +731,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -753,7 +765,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: string array) = + let rec group directives stack (sourceLines: ReadOnlyMemory[]) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -822,36 +834,29 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - /// Determine if a line is a single line or xml documentation comment - let (|Comment|_|) (line: string) = - if line.StartsWithOrdinal("///") then Some XmlDoc - elif line.StartsWithOrdinal("//") then Some SingleLine - else None - - let getCommentRanges trivia (lines: string[]) = - let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = - match lines with - | [] -> state - | lineStr :: rest -> - match lineStr.TrimStart(), currentComment with - | Comment commentType, Some comment -> + let getCommentRanges trivia (lines: ReadOnlyMemory[]) = + let rec loop (struct (lastLineNum, currentComment, result) as state) lineNum = + if lineNum = lines.Length then + state + else + match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add(lineNum, lineStr) - lineNum, currentComment, result + comment.Lines.Add lineNum + struct (lineNum, currentComment, result) else - let comments = CommentList.New commentType (lineNum, lineStr) - lineNum, Some comments, comment :: result) - rest + let comments = CommentList.New commentType lineNum + struct (lineNum, Some comments, comment :: result)) (lineNum + 1) - | Comment commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) - loop (lineNum, Some comments, result) rest (lineNum + 1) - | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) - | _ -> loop (lineNum, None, result) rest (lineNum + 1) + | ValueSome commentType, None -> + let comments = CommentList.New commentType lineNum + loop (struct (lineNum, Some comments, result)) (lineNum + 1) + | ValueNone, Some comment -> loop (struct (lineNum, None, comment :: result)) (lineNum + 1) + | ValueNone, None -> loop (struct (lineNum, None, result)) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let struct (_, lastComment, comments) = loop (struct (-1, None, [])) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +864,12 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.IndexOf '/' - let endCol = endStr.TrimEnd().Length + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.map (fun comment -> + let startLine = comment.Lines[0] + let endLine = comment.Lines[comment.Lines.Count - 1] + let startCol = lines[startLine].Span.IndexOf '/' + let endCol = lines[endLine].Span.TrimEnd().Length let scopeType = match comment.Type with @@ -1104,3 +1108,6 @@ module Structure = getCommentRanges file.Trivia.CodeComments sourceLines acc :> seq<_> + + let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + getOutliningRangesFromLineSlices (sourceLines |> Array.map _.AsMemory()) parsedInput diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..b2e4f14ff18 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -80,3 +81,10 @@ module public Structure = /// Returns outlining ranges for given parsed input. val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + + /// + /// Returns outlining ranges for given parsed input, taking the source lines as slices of text the + /// caller already holds rather than as strings of their own. + /// + val internal getOutliningRangesFromLineSlices: + sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 244158619d2..27f04424999 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,6 +7,7 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO +open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -112,6 +113,63 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.StartsWith(value, StringComparison.Ordinal) + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.OrdinalIgnoreCase) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) + + static member IndexOf(str: ReadOnlySpan, value: char) = + let mutable index = -1 + let mutable i = 0 + + while i < str.Length && index = -1 do + if str[i] = value then index <- i else i <- i + 1 + + index + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = + str.IndexOf(value.AsSpan(), StringComparison.Ordinal) + + // Searching a slice answers with an index into that slice, so the offset goes back on to + // report a position in `str` - what the String siblings these mirror return. A miss stays -1. + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + let i = str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + let i = str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + let i = str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + let i = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + if i < 0 then i else i + startIndex + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 0ee41f441b5..b892f3418d9 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,67 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str: ReadOnlySpan * value: string -> bool + + [] + static member IndexOf: str: ReadOnlySpan * value: char -> int + + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> int + + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string * startIndex: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: string * startIndex: int * count: int -> int + type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index e043d8554ad..39f9165e8f5 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -223,4 +223,12 @@ + + + + + diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..89185ebe556 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,6 +296,14 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) + /// The lines of the text, as slices of a single string rather than one string per line. + member this.GetLinesAsMemory() = + let text = this.ToString() + + Array.init this.Lines.Count (fun i -> + let line = this.Lines[i] + text.AsMemory(line.Start, line.End - line.Start)) + type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index d0326d84311..f4fcad313ca 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,9 +119,9 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray + let linetext = sourceText.GetLinesAsMemory() - Structure.getOutliningRanges linetext parsedInput + Structure.getOutliningRangesFromLineSlices linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) |> Seq.chooseV (fun scopeRange -> // the range of text to collapse