From 652ddb44c2589271316dbd9932263b9febd9a67f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:01:34 +0200 Subject: [PATCH 1/9] 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 9441f8587e0..7a401599b71 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())`. 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 5842492b31d..d180caa76a1 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 7c598ffa7a2..3aa533289eb 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 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) From f1d07cfc0c88f86be75e7a6d6c8af3b5a3f87fc0 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:36:11 +0200 Subject: [PATCH 2/9] 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 27b19d41b369672ce6453659fb3fefeba3ffd995 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:39:21 +0200 Subject: [PATCH 3/9] 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 7a401599b71..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,4 +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())`. +* `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 2200592bf9003a6c5a5a90c1adacec6126709e3e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:54:31 +0200 Subject: [PATCH 4/9] 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 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/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 3aa533289eb..65423b11139 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 ad7766c6f1898d5f500e1f91a7ea2795cd821afc Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:56:07 +0200 Subject: [PATCH 5/9] 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 46ee862b6934fedc606303df97cbebc5cc6e531d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 01:51:46 +0200 Subject: [PATCH 6/9] 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 fadd55d302b96f3120d24bd0c981f09907cbb70d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 18:13:35 +0200 Subject: [PATCH 7/9] 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 d180caa76a1..a1f46fe4b48 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 65423b11139..c3797de6c92 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 8e642e7751eb220987b37912ab03a5823c703578 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 18:28:19 +0200 Subject: [PATCH 8/9] 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 a1f46fe4b48..3b09abab981 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 c3797de6c92..1a201c1b3cf 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 6c8949ccabdf236b46f358f292fbf53b4de75412 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 4 Sep 2026 19:20:01 +0200 Subject: [PATCH 9/9] 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 1a201c1b3cf..cad5703174b 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