Skip to content

Commit dc46686

Browse files
xperiandriclaude
andcommitted
Enter a one-line nested module only from its own line
A nested module whose body starts on the header's line is entered at its first declaration, and the walk gated that by the line above the header. A use on the line above the module then got its open inside the module, where it does not reach the use. The completion that adds an open asked for that same line and composed its change from a position past the item, which threw ArgumentOutOfRangeException. The module is now entered from the header's line on. When an attribute shares that line with the body, the use may be the attribute, which the enclosing scope resolves, so the open stays there. The body of GetChangeAsync moves into a static GetChangeAsyncAux, as ProvideCompletionsAsyncAux already is, so that a test can commit a completion item without a Visual Studio service provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d34a393 commit dc46686

5 files changed

Lines changed: 163 additions & 22 deletions

File tree

‎src/Compiler/Service/ServiceParsedInputOps.fs‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2528,6 +2528,7 @@ module ParsedInput =
25282528
match decl with
25292529
| SynModuleDecl.NamespaceFragment fragment -> walkSynModuleOrNamespace parent fragment
25302530
| SynModuleDecl.NestedModule(moduleInfo = compInfo; decls = decls; range = range; trivia = trivia) ->
2531+
let (SynComponentInfo(attributes = attributes)) = compInfo
25312532
let ident = compInfo.LongIdent
25322533
let fullIdent = parent @ ident
25332534
addModule (fullIdent, range)
@@ -2545,16 +2546,32 @@ module ParsedInput =
25452546
|> ValueOption.map _.Column
25462547
|> ValueOption.defaultValue (range.StartColumn + 4)
25472548

2549+
let bodyStartsOnHeaderLine =
2550+
match firstDeclStart with
2551+
| ValueSome start -> start.Line = headerLine
2552+
| ValueNone -> false
2553+
25482554
// The open goes on the line below this one. A body that starts on the header's own
25492555
// line (`module M = let x = 1`) is entered at its first declaration instead, since
25502556
// the line below the header may already be past the use the open has to precede.
25512557
let lineAboveBody =
2552-
match firstDeclStart with
2553-
| ValueSome start when start.Line = headerLine -> headerLine - 1
2554-
| _ -> headerLine
2555-
2556-
doRange NestedModule fullIdent lineAboveBody moduleBodyIndentation
2557-
List.iter (walkSynModuleDecl fullIdent) decls
2558+
if bodyStartsOnHeaderLine then
2559+
headerLine - 1
2560+
else
2561+
headerLine
2562+
2563+
// A use on the line the header shares with the body is in that body or in an attribute
2564+
// of the module, which the enclosing scope resolves. The line alone cannot tell the two
2565+
// apart, and an open in the enclosing scope serves both.
2566+
let mayBeInAttribute =
2567+
bodyStartsOnHeaderLine
2568+
&& currentLine = headerLine
2569+
&& attributes
2570+
|> List.exists (fun attributeList -> attributeList.Range.EndLine = headerLine)
2571+
2572+
if headerLine <= currentLine && not mayBeInAttribute then
2573+
doRange NestedModule fullIdent lineAboveBody moduleBodyIndentation
2574+
List.iter (walkSynModuleDecl fullIdent) decls
25582575
| SynModuleDecl.Open(_, range) -> doRange OpenDeclaration [] range.EndLine (range.StartColumn - 5)
25592576
| SynModuleDecl.HashDirective(_, range) -> doRange HashDirective [] range.EndLine range.StartColumn
25602577
| _ -> ()

‎tests/FSharp.Compiler.Service.Tests/OpenDeclarationInsertionTests.fs‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ open Xunit
44
open FSharp.Compiler.EditorServices
55
open FSharp.Compiler.Service.Tests.Common
66

7-
/// Computes the position an `open` would be inserted at, mirroring the real editor consumer
8-
/// (RoslynHelpers.insertContext): `FindNearestPointToInsertOpenDeclaration` followed by
7+
/// Computes the position an `open` would be inserted at for a use on `currentLine`, mirroring the real
8+
/// editor consumer (RoslynHelpers.insertContext): `FindNearestPointToInsertOpenDeclaration` followed by
99
/// `AdjustInsertionPoint`. Asserting on the adjusted position is what the user actually sees.
10-
let private findOpenInsertion (insertionPoint: OpenStatementInsertionPoint) (fileName: string) (source: string) (entity: string) =
10+
let private findOpenInsertionAt
11+
(currentLine: int)
12+
(insertionPoint: OpenStatementInsertionPoint)
13+
(fileName: string)
14+
(source: string)
15+
(entity: string)
16+
=
1117
let parseTree = parseSourceCode (fileName, source)
1218

1319
let lines = source.Replace("\r\n", "\n").Split('\n')
1420

15-
let currentLine =
16-
let mutable i = lines.Length - 1
17-
while i > 0 && System.String.IsNullOrWhiteSpace(lines.[i]) do
18-
i <- i - 1
19-
i + 1
20-
2121
let idents = entity.Split('.')
2222

2323
let ctx =
@@ -29,6 +29,18 @@ let private findOpenInsertion (insertionPoint: OpenStatementInsertionPoint) (fil
2929
let pos = ParsedInput.AdjustInsertionPoint getLineStr ctx
3030
pos.Line, pos.Column
3131

32+
/// The same for a use on the last line of the source that is not blank.
33+
let private findOpenInsertion (insertionPoint: OpenStatementInsertionPoint) (fileName: string) (source: string) (entity: string) =
34+
let lines = source.Replace("\r\n", "\n").Split('\n')
35+
36+
let currentLine =
37+
let mutable i = lines.Length - 1
38+
while i > 0 && System.String.IsNullOrWhiteSpace(lines.[i]) do
39+
i <- i - 1
40+
i + 1
41+
42+
findOpenInsertionAt currentLine insertionPoint fileName source entity
43+
3244
let private findOpenInsertionLine fileName source entity =
3345
findOpenInsertion OpenStatementInsertionPoint.TopLevel fileName source entity |> fst
3446

@@ -154,6 +166,15 @@ module M = let x = System.IO.File.ReadAllText "a"
154166
let pos = findOpenInsertion OpenStatementInsertionPoint.Nearest "test.fs" source "System.IO"
155167
Assert.Equal((3, 11), pos)
156168

169+
[<Theory>] // Regression: the open has to stay in `Top`, the scope of the use, and not enter `M`
170+
[<InlineData("module M = let y = 1", 2)>]
171+
[<InlineData("[<Obsolete>] module M = let y = 1", 2)>]
172+
[<InlineData("[<Obsolete>] module M = let y = 1", 3)>] // the use is the attribute on the header's line
173+
let ``Open stays out of a nested module declared on one line that the use is not in`` (nestedModule: string, useLine: int) =
174+
let source = $"module Top\nlet x = Console.WriteLine 42\n{nestedModule}\n"
175+
let pos = findOpenInsertionAt useLine OpenStatementInsertionPoint.Nearest "test.fs" source "System"
176+
Assert.Equal((2, 0), pos)
177+
157178
[<Fact>] // Only #r/#load drive placement; other directives (#time/#help/#I/...) must not
158179
let ``Non reference directive does not drive placement in fsx`` () =
159180
let source = """#time "on"

‎vsintegration/src/FSharp.Editor/Completion/CompletionProvider.fs‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,13 @@ type internal FSharpCompletionProvider
405405
| true, keywordDescription -> Task.FromResult(CompletionDescription.FromText(keywordDescription))
406406
| false, _ -> Task.FromResult(CompletionDescription.Empty)
407407

408-
override _.GetChangeAsync(document, item, _, cancellationToken) : Task<CompletionChange> =
408+
static member GetChangeAsyncAux(document: Document, item: Completion.CompletionItem, insertionPoint: OpenStatementInsertionPoint) =
409409
cancellableTask {
410410
use _logBlock =
411411
Logger.LogBlockMessage document.Name LogEditorFunctionId.Completion_GetChangeAsync
412412

413+
let! cancellationToken = CancellableTask.getCancellationToken ()
414+
413415
let fullName =
414416
match item.Properties.TryGetValue FullNamePropName with
415417
| true, x -> ValueSome x
@@ -469,12 +471,6 @@ type internal FSharpCompletionProvider
469471
|> ValueOption.map (fun x -> x.Split '.')
470472
|> ValueOption.defaultValue [||]
471473

472-
let insertionPoint =
473-
if settings.CodeFixes.AlwaysPlaceOpensAtTopLevel then
474-
OpenStatementInsertionPoint.TopLevel
475-
else
476-
OpenStatementInsertionPoint.Nearest
477-
478474
let ctx =
479475
ParsedInput.FindNearestPointToInsertOpenDeclaration
480476
line.LineNumber
@@ -494,4 +490,13 @@ type internal FSharpCompletionProvider
494490

495491
return CompletionChange.Create(TextChange(fullChangingSpan, changedText)).WithNewPosition(Nullable(changedSpan.End))
496492
}
493+
494+
override _.GetChangeAsync(document, item, _, cancellationToken) : Task<CompletionChange> =
495+
let insertionPoint =
496+
if settings.CodeFixes.AlwaysPlaceOpensAtTopLevel then
497+
OpenStatementInsertionPoint.TopLevel
498+
else
499+
OpenStatementInsertionPoint.Nearest
500+
501+
FSharpCompletionProvider.GetChangeAsyncAux(document, item, insertionPoint)
497502
|> CancellableTask.start cancellationToken

‎vsintegration/tests/FSharp.Editor.Tests/CodeFixes/AddOpenOnTopOffTests.fs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ let ``Fixes FS0039 for missing opens - nested module declared on one line`` () =
205205

206206
Assert.Equal(expected, actual)
207207

208+
[<Theory>] // The open has to stay in `Module1`, the scope of the use, and not enter `Module2`
209+
[<InlineData("let x = Console.WriteLine 42\n[<Obsolete>] module Module2 = let y = 1\n")>]
210+
[<InlineData("[<Obsolete>] module Module2 = let y = 1\n")>]
211+
let ``Fixes FS0039 for missing opens - the use is not in the nested module declared on one line`` (declarations: string) =
212+
let code = "module Module1\n" + declarations
213+
214+
let expected =
215+
Some
216+
{
217+
Message = "open System"
218+
FixedCode = "module Module1\n\nopen System\n\n" + declarations
219+
}
220+
221+
let actual = codeFix |> tryFix code mode
222+
223+
Assert.Equal(expected, actual)
224+
208225
[<Fact>]
209226
let ``Fixes FS0039 for missing opens - explicit module has attributes`` () =
210227
let code =

‎vsintegration/tests/FSharp.Editor.Tests/CompletionProviderTests.fs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,68 @@ module CompletionProviderTests =
149149

150150
Assert.Equal(expected, sourceText.ToString(resultSpan))
151151

152+
/// Verify the text that committing an item from a namespace that is not opened leaves, the `open` included.
153+
let VerifyCompletionWithOpen (fileContents: string, marker: string, itemName: string, expected: string) =
154+
let caretPosition = fileContents.IndexOf(marker) + marker.Length
155+
156+
let document =
157+
RoslynTestHelpers.CreateSolution(fileContents)
158+
|> RoslynTestHelpers.GetSingleDocument
159+
160+
let sourceText = SourceText.From(fileContents)
161+
162+
let span =
163+
CompletionUtils.getDefaultCompletionListSpan (
164+
sourceText,
165+
caretPosition,
166+
document.Id,
167+
filePath,
168+
[],
169+
None,
170+
CancellationToken.None
171+
)
172+
173+
let assemblyContentProvider = AssemblyContentProvider()
174+
175+
// A completion item gets its span from the context it is added to.
176+
let context =
177+
let provider =
178+
{ new CompletionProvider() with
179+
member _.ProvideCompletionsAsync _ = Tasks.Task.CompletedTask
180+
}
181+
182+
CompletionContext(provider, document, caretPosition, span, CompletionTrigger.Invoke, null, CancellationToken.None)
183+
184+
let change =
185+
let task =
186+
cancellableTask {
187+
let! items =
188+
FSharpCompletionProvider.ProvideCompletionsAsyncAux(
189+
document,
190+
caretPosition,
191+
assemblyContentProvider.GetAllEntitiesInProjectAndReferencedAssemblies,
192+
false
193+
)
194+
195+
let item =
196+
items
197+
|> Seq.find (fun item -> item.DisplayText = itemName && item.Properties.ContainsKey "NamespaceToOpen")
198+
199+
context.AddItem item
200+
201+
return!
202+
FSharpCompletionProvider.GetChangeAsyncAux(
203+
document,
204+
item,
205+
FSharp.Compiler.EditorServices.OpenStatementInsertionPoint.Nearest
206+
)
207+
}
208+
|> CancellableTask.start CancellationToken.None
209+
210+
task.Result
211+
212+
Assert.Equal(expected, sourceText.WithChanges(change.TextChange).ToString())
213+
152214
[<Fact>]
153215
let ShouldTriggerCompletionAtCorrectMarkers () =
154216
let testCases =
@@ -2167,3 +2229,22 @@ let _ =
21672229
]
21682230
|> Map.ofList
21692231
)
2232+
2233+
[<Fact>]
2234+
let ``Completion that adds an open keeps it out of a nested module declared on one line`` () =
2235+
let fileContents =
2236+
"""module Top
2237+
let x = Console.WriteLine 42
2238+
[<Obsolete>] module M = let y = 1
2239+
"""
2240+
2241+
let expected =
2242+
"""module Top
2243+
2244+
open System
2245+
2246+
let x = Console.WriteLine 42
2247+
[<Obsolete>] module M = let y = 1
2248+
"""
2249+
2250+
VerifyCompletionWithOpen(fileContents, "[<Obsol", "Obsolete", expected)

0 commit comments

Comments
 (0)