From 27d524aa47e53fe3b97db901b372833eedd5c406 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:01:34 +0200 Subject: [PATCH 01/11] Slice source text instead of copying it line by line for outlining The editor's block structure built the sourceLines array for Structure.getOutliningRanges by calling ToString() per line, allocating a fresh string for the entire file on every outlining pass, once per keystroke. getOutliningRanges now takes ReadOnlyMemory[] and slices the already-materialized source text once (SourceText.GetLinesAsMemory()) instead. ReadOnlySpanCharExtensions in illib mirrors the existing Ordinal string helpers so span call sites read the same way string call sites do. A local recursive function closing over a ReadOnlySpan-typed sibling cannot be compiled - the CLR disallows instantiating FSharpFunc, _> as a closure field (FS0412) - so commentTypeOf moves to module scope, next to the CommentType it classifies. StructureTests.fs slices its own lines the same way at the call site, and FSharp.Compiler.Service.Tests needs a direct System.Memory PackageReference: FSharp.Compiler.Service's own reference to it is only transitive through the net472 ProjectReference's SetTargetFramework override, mirroring the FSharp.Core pin already in this project for the same reason. Co-Authored-By: Claude Fable 5.1 --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Service/ServiceStructure.fs | 61 ++++++++++--------- src/Compiler/Service/ServiceStructure.fsi | 3 +- src/Compiler/Utilities/illib.fs | 49 +++++++++++++++ src/Compiler/Utilities/illib.fsi | 42 +++++++++++++ ...iler.Service.SurfaceArea.netstandard20.bsl | 2 +- .../FSharp.Compiler.Service.Tests.fsproj | 6 ++ .../StructureTests.fs | 3 +- .../src/FSharp.Editor/Common/Extensions.fs | 8 +++ .../Structure/BlockStructureService.fs | 2 +- 10 files changed, 145 insertions(+), 32 deletions(-) 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..cb0e3aa2b95 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -221,3 +221,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())`. diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..f686d9ec452 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,12 +187,21 @@ module Structure = } type LineNumber = int - type LineStr = string + type LineStr = ReadOnlyMemory type CommentType = | SingleLine | XmlDoc + /// Determine if a line is a single line or xml documentation comment. + /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed + /// helper as a closure field would need to instantiate `FSharpFunc, _>`, + /// 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 = { @@ -206,7 +216,7 @@ module Structure = } /// 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 +671,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 (fun line -> line.Span.IsWhiteSpace()) -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +729,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 +763,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,19 +832,15 @@ 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 (lastLineNum, currentComment, result as state) lineNum = + if lineNum = lines.Length then + state + else + let lineStr = lines[lineNum] + + match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then comment.Lines.Add(lineNum, lineStr) @@ -842,16 +848,15 @@ module Structure = else let comments = CommentList.New commentType (lineNum, lineStr) lineNum, Some comments, comment :: result) - rest (lineNum + 1) - | Comment commentType, None -> + | ValueSome 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) + loop (lineNum, Some comments, result) (lineNum + 1) + | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) + | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let _, lastComment, comments = loop (-1, None, []) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +864,13 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.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 + let startCol = startStr.Span.IndexOf '/' + let endCol = endStr.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 244158619d2..c3a6ebb5ca8 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,54 @@ 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) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + /// 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..f77340b9e7b 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,48 @@ 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 + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + [] + 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..0183589a540 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,10 @@ + + + + + diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..d3bbe73e4b9 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 (fun line -> line.AsMemory())) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort 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..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) From aad1f26579aca3ac306cbe0c7f82275c90ba16dd Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:36:11 +0200 Subject: [PATCH 02/11] Track comment lines by number instead of storing their text CommentList kept a copy of every comment line next to its line number, but the number alone identifies the line in the source array the function already holds, and only the first and last lines of a group are ever read back to compute the fold's columns. Store the numbers and index the source at the end, so grouping comments allocates no tuple per line. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Service/ServiceStructure.fs | 26 ++-- .../StructureTests.fs | 116 +++++++++--------- 2 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index f686d9ec452..6937981ecec 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -187,7 +187,6 @@ module Structure = } type LineNumber = int - type LineStr = ReadOnlyMemory type CommentType = | SingleLine @@ -205,14 +204,14 @@ module Structure = [] 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. @@ -837,20 +836,18 @@ module Structure = if lineNum = lines.Length then state else - let lineStr = lines[lineNum] - - match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + 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) + comment.Lines.Add lineNum lineNum, currentComment, result else - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum lineNum, Some comments, comment :: result) (lineNum + 1) | ValueSome commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum loop (lineNum, Some comments, result) (lineNum + 1) | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) @@ -866,11 +863,10 @@ module Structure = comments |> Seq.filter (fun comment -> comment.Lines.Count > 1) |> Seq.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.Span.IndexOf '/' - let endCol = endStr.Span.TrimEnd().Length + 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/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index d3bbe73e4b9..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -36,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges (lines |> Array.map (fun line -> line.AsMemory())) 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 @@ -152,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -164,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -190,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) @@ -227,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) ] @@ -326,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -383,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -449,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -468,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -478,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -660,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int From 2e1e5610e38a75d44d64c1be50765c539273ed99 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:39:21 +0200 Subject: [PATCH 03/11] Link the outlining release note to its PR Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cb0e3aa2b95..3ecc91398f4 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -221,4 +221,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())`. +* `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)) From 01de7b92a300a366a242a916233bb4f130c05ee5 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:54:31 +0200 Subject: [PATCH 04/11] Format illib.fsi and add the VisualStudio release note CheckCodeFormatting flagged illib.fsi for a stray space before the colon in the ReadOnlySpanCharExtensions signatures; dotnet fantomas fixes it mechanically, no signature changes. check_release_notes also requires an entry for changes under vsintegration/src. Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + src/Compiler/Utilities/illib.fsi | 29 +++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) 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/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index f77340b9e7b..4ebde92f8bc 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -72,43 +72,46 @@ module internal PervasiveAutoOpens = type ReadOnlySpanCharExtensions = [] - static member inline StartsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool [] - static member inline StartsWithOrdinal: str : ReadOnlySpan * value: string -> 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: ReadOnlySpan -> bool [] - static member inline EndsWithOrdinal: str : ReadOnlySpan * value: string -> 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: ReadOnlySpan -> bool [] - static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: string -> bool + static member inline EndsWithOrdinalIgnoreCase: str: ReadOnlySpan * value: string -> bool [] - static member IndexOf: str : ReadOnlySpan * value: char -> int + static member IndexOf: str: ReadOnlySpan * value: char -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> int + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string -> int + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string * startIndex: int -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int [] - static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int * count: int -> int + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: string * startIndex: int * count: int -> int type Async with From 181f7442fab081b8479c2e9bc2410f5bf253fc1f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:56:07 +0200 Subject: [PATCH 05/11] Scope the System.Memory pin to the net472 test build Plain_Build_Windows and Plain_Build_Linux both failed with NU1510: on the .NET Core inner build System.Memory ships with the framework, and NuGet's package-pruning check treats an unconditional explicit PackageReference to it as an error. The pin is only needed on net472, where FSharp.Compiler.Service's own PackageReference to System.Memory doesn't flow through the netstandard2.0 SetTargetFramework override. Co-Authored-By: Claude Fable 5.1 --- .../FSharp.Compiler.Service.Tests.fsproj | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 0183589a540..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,9 +223,11 @@ - + + transitive here, so the SetTargetFramework override above does not carry it in on net472; pin it directly. + Not needed on the .NET Core inner build: there System.Memory ships with the framework and NuGet's + package-pruning check (NU1510) errors on an explicit reference to it. --> From fd1adff5e5f38c37690c6a44c50fd27168a4a80d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 01:51:46 +0200 Subject: [PATCH 06/11] Address review: XML doc structure and shorthand lambda Wrap commentTypeOf's doc comment in , move the FS0412 rationale into , and reference the types through rather than inline code spans. Use the shorthand lambda for the whitespace check, per review suggestion. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Service/ServiceStructure.fs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 6937981ecec..db5eb5581d5 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -192,10 +192,14 @@ module Structure = | SingleLine | XmlDoc - /// Determine if a line is a single line or xml documentation comment. - /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed - /// helper as a closure field would need to instantiate `FSharpFunc, _>`, - /// which the CLR disallows for byref-like type arguments (FS0412). + /// + /// 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 @@ -670,7 +674,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall (fun line -> line.Span.IsWhiteSpace()) + |> Array.forall _.Span.IsWhiteSpace() -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] From 9d2dee41b03d89c4e8d24f727e6ab904b496506e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 18:13:35 +0200 Subject: [PATCH 07/11] Drop the span IndexOfOrdinal overloads that take a start index Slicing before searching makes the result relative to the slice, while the String siblings these mirror return an index into the whole string. A call ported from the string path would land a column short by startIndex, and "not found" would come back as -1 from the slice rather than from the string. Nothing calls them. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Utilities/illib.fs | 12 ------------ src/Compiler/Utilities/illib.fsi | 15 --------------- 2 files changed, 27 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index c3a6ebb5ca8..4cf77935d60 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -149,18 +149,6 @@ module internal PervasiveAutoOpens = static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = str.IndexOf(value.AsSpan(), StringComparison.Ordinal) - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = - str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = - str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = - str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) - - static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = - str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) - /// 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 4ebde92f8bc..4de3dbfb66b 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -98,21 +98,6 @@ module internal PervasiveAutoOpens = [] static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string -> int - [] - static member inline IndexOfOrdinal: - str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int - - [] - static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string * startIndex: int -> int - - [] - static member inline IndexOfOrdinal: - str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int - - [] - 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. From 877a467f3cb5c119418e7d47ddf989bdac332d23 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 18:28:19 +0200 Subject: [PATCH 08/11] Report an absolute index from the span IndexOfOrdinal overloads Restores the startIndex overloads dropped in c9fbf5a36f, this time reporting the position in the span they were given rather than in the slice they searched, which is what the String siblings they mirror return. A miss still comes back as -1 rather than as startIndex - 1. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Utilities/illib.fs | 21 +++++++++++++++++++++ src/Compiler/Utilities/illib.fsi | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 4cf77935d60..27f04424999 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -149,6 +149,27 @@ module internal PervasiveAutoOpens = 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 4de3dbfb66b..fba19d3eccd 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -98,6 +98,29 @@ module internal PervasiveAutoOpens = [] 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. From 90ca6fd7232e1ec97ae454c6f0b37813b9577909 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 19:20:01 +0200 Subject: [PATCH 09/11] Wrap the IndexOfOrdinal startIndex overloads' doc text in summary A doc comment that carries markup like / needs that text inside - otherwise it renders as raw text in the generated XML and in tooltips. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Utilities/illib.fsi | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index fba19d3eccd..b892f3418d9 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -98,25 +98,33 @@ module internal PervasiveAutoOpens = [] 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 From 52064dcb6ea65a5d09e115a6f173c3efea82087e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 22:20:16 +0200 Subject: [PATCH 10/11] Use a struct tuple for the comment-scan accumulator getCommentRanges recurses once per line, threading a three-way state through every call; a reference tuple heap-allocates on each of those recursive calls, a struct tuple doesn't. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Service/ServiceStructure.fs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index db5eb5581d5..72e2ddf9943 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -836,7 +836,7 @@ module Structure = List.iter parseDeclaration decls let getCommentRanges trivia (lines: ReadOnlyMemory[]) = - let rec loop (lastLineNum, currentComment, result as state) lineNum = + let rec loop (struct (lastLineNum, currentComment, result) as state) lineNum = if lineNum = lines.Length then state else @@ -845,19 +845,19 @@ module Structure = loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then comment.Lines.Add lineNum - lineNum, currentComment, result + struct (lineNum, currentComment, result) else let comments = CommentList.New commentType lineNum - lineNum, Some comments, comment :: result) + struct (lineNum, Some comments, comment :: result)) (lineNum + 1) | ValueSome commentType, None -> let comments = CommentList.New commentType lineNum - loop (lineNum, Some comments, result) (lineNum + 1) - | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) - | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) + 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, []) 0 + let struct (_, lastComment, comments) = loop (struct (-1, None, [])) 0 match lastComment with | Some comment -> comment :: comments From 95468284b0b5d3ff8ac5e9c7ff2c2c5171d8295b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 9 Sep 2026 18:10:55 +0200 Subject: [PATCH 11/11] Keep getOutliningRanges taking string[] and add a slice entry beside it Changing the signature made every FSharp.Compiler.Service consumer of the outlining API pay for a caller the editor alone has. The scanner now sits behind two entry points: the public one keeps its string[] parameter and maps to memory, and getOutliningRangesFromLineSlices takes the slices the editor already holds. The public surface is unchanged, so the surface-area baseline and the StructureTests calls return to what they were, and the release note moves out of Breaking Changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../.FSharp.Compiler.Service/11.0.100.md | 2 +- src/Compiler/Service/ServiceStructure.fs | 6 +- src/Compiler/Service/ServiceStructure.fsi | 9 +- ...iler.Service.SurfaceArea.netstandard20.bsl | 2 +- .../StructureTests.fs | 117 +++++++++--------- .../Structure/BlockStructureService.fs | 2 +- 6 files changed, 73 insertions(+), 65 deletions(-) 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 3ecc91398f4..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)) @@ -221,4 +222,3 @@ * `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/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 72e2ddf9943..e40ddda9e73 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -218,8 +218,7 @@ module Structure = Lines = ResizeArray [ lineNum ] } - /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (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 @@ -1109,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 3695e7148ac..b2e4f14ff18 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -80,4 +80,11 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq + 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/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 7f4e7d14ec4..5c9c346b613 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.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], 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/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index 322a0fa3bda..c0ae0d3fdff 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,6 +1,5 @@ module FSharp.Compiler.Service.Tests.StructureTests -open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -36,7 +35,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges (lines |> Array.map _.AsMemory()) ast + getOutliningRanges lines ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -152,7 +151,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -164,7 +163,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -190,31 +189,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) @@ -227,28 +226,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) ] @@ -326,7 +325,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -383,7 +382,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -449,7 +448,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -468,7 +467,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -478,10 +477,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -660,7 +659,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index b087cb56782..f4fcad313ca 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -121,7 +121,7 @@ module internal BlockStructure = let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = 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