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 9441f8587e0..f867cda9c09 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -216,3 +216,4 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. ([PR #20443](https://github.com/dotnet/fsharp/pull/20443)) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ef64e1a75c4..ccd6c2c4566 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -20,3 +20,4 @@ * Rename "inline hints" to "inlay hints" in VS options for consistency with industry terminology. ([PR #19318](https://github.com/dotnet/fsharp/pull/19318)) * 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)) +* 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..72e2ddf9943 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,39 @@ 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 getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +674,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 +732,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 +766,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 +835,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 +865,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 diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..3695e7148ac 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 @@ -79,4 +80,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 5842492b31d..3b09abab981 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 7c598ffa7a2..cad5703174b 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.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 5c9c346b613..7f4e7d14ec4 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -4744,7 +4744,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements 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/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,5 +1,6 @@ module FSharp.Compiler.Service.Tests.StructureTests +open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -35,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges lines ast + getOutliningRanges (lines |> Array.map _.AsMemory()) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -151,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -163,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -189,31 +190,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -226,28 +227,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -325,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -382,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -448,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -467,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -477,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -659,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 82d161a7e64..c313a0bc083 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..b087cb56782 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ 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 |> Seq.distinctBy (fun x -> x.Range.StartLine)