From 1970b8a58f31675c88e5cfffb2fda8f10008a460 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 01:31:44 +0200 Subject: [PATCH 1/4] Add a refactoring between a namespace with one nested module and a root-level module With the caret on the header, 'namespace A.B' holding a single 'module C =' converts to 'module A.B.C', and 'module A.B.C' converts back. The compiler treats the two forms as the same module, so the edit only rewrites the header and moves the body by the nested module's indentation; lines inside multi-line string literals keep their text. Files with a signature file are not offered the conversion, because converting one side would unpair it from the other. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../Refactor/ConvertNamespaceModule.fs | 315 ++++++++++++++++++ .../Telemetry/TelemetryReporter.fs | 3 + .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 + .../xlf/FSharp.Editor.zh-Hans.xlf | 10 + .../xlf/FSharp.Editor.zh-Hant.xlf | 10 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ConvertNamespaceModuleTests.fs | 104 ++++++ 20 files changed, 561 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..070b6bd9306 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,7 @@ * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) +* Refactoring to convert a file written as `namespace A.B` with a single nested `module C =` into a root-level `module A.B.C`, and a root-level `module A.B.C` back into `namespace A.B` with a nested `module C =`. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..eb83c75fba2 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -105,6 +105,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 1f1f632d770..c2c49323b9c 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -368,4 +368,10 @@ Use live (unsaved) buffers for analysis Returns: + + Convert to 'module {0}' + + + Convert to 'namespace {0}' with nested module '{1}' + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs new file mode 100644 index 00000000000..2c5e7610cac --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.Composition + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor.Telemetry + +open FSharp.Compiler.Syntax +open FSharp.Compiler.SyntaxTrivia +open FSharp.Compiler.Text + +open CancellableTasks + +[] +module private NamespaceModuleConversion = + + [] + type Shape = + | Nested of + namespacePath: LongIdent * + namespaceIsRecursive: bool * + namespaceKeyword: range * + opens: range list * + moduleIdent: Ident * + moduleIsRecursive: bool * + moduleKeyword: range * + equals: range * + bodyColumn: int + | Root of path: LongIdent * keyword: range * moduleRange: range + + let spanOf (sourceText: SourceText) (m: range) = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, m) + + let textBetween (sourceText: SourceText) (first: Ident) (last: Ident) = + sourceText.ToString(TextSpan.FromBounds((spanOf sourceText first.idRange).Start, (spanOf sourceText last.idRange).End)) + + let isBlank (sourceText: SourceText) start finish = + String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(start, finish))) + + let restOfLineIsBlank (sourceText: SourceText) (m: range) = + let position = (spanOf sourceText m).End + isBlank sourceText position (sourceText.Lines.GetLineFromPosition position).End + + /// The declarations of a namespace are a single nested module, optionally preceded by `open`s that would + /// apply equally to a root module: moving them into it changes nothing they resolve against. + let private tryOpensBeforeNestedModule (decls: SynModuleDecl list) = + match List.rev decls with + | nestedModule :: reversedOpens when + reversedOpens + |> List.forall (function + | SynModuleDecl.Open _ -> true + | _ -> false) + -> + match nestedModule with + | SynModuleDecl.NestedModule _ -> Some(reversedOpens |> List.rev |> List.map _.Range, nestedModule) + | _ -> None + | _ -> None + + let tryShape (sourceText: SourceText) (parseTree: ParsedInput) = + match parseTree with + | ParsedInput.ImplFile(ParsedImplFileInput( + contents = [ SynModuleOrNamespace( + longId = namespacePath + isRecursive = namespaceIsRecursive + kind = SynModuleOrNamespaceKind.DeclaredNamespace + decls = decls + trivia = namespaceTrivia) ])) -> + match tryOpensBeforeNestedModule decls with + | Some(opens, + SynModuleDecl.NestedModule( + moduleInfo = moduleInfo + isRecursive = moduleIsRecursive + decls = (firstDeclaration :: _ as declarations) + range = moduleRange + trivia = moduleTrivia)) -> + match namespaceTrivia.LeadingKeyword, moduleInfo.LongIdent, moduleTrivia.ModuleKeyword, moduleTrivia.EqualsRange with + | SynModuleOrNamespaceLeadingKeyword.Namespace namespaceKeyword, [ moduleIdent ], Some moduleKeyword, Some equals when + namespaceKeyword.StartColumn = 0 + && moduleKeyword.StartColumn = 0 + && moduleKeyword.StartLine = equals.EndLine + && firstDeclaration.Range.StartLine > equals.EndLine + && Position.posEq moduleRange.End (List.last declarations).Range.End + && restOfLineIsBlank sourceText (List.last namespacePath).idRange + -> + ValueSome( + Nested( + namespacePath, + namespaceIsRecursive, + namespaceKeyword, + opens, + moduleIdent, + moduleIsRecursive, + moduleKeyword, + equals, + firstDeclaration.Range.StartColumn + ) + ) + | _ -> ValueNone + | _ -> ValueNone + + | ParsedInput.ImplFile(ParsedImplFileInput( + contents = [ SynModuleOrNamespace( + longId = (_ :: _ :: _ as path) + kind = SynModuleOrNamespaceKind.NamedModule + decls = firstDeclaration :: _ + range = moduleRange + trivia = moduleTrivia) ])) -> + match moduleTrivia.LeadingKeyword with + | SynModuleOrNamespaceLeadingKeyword.Module keyword when + keyword.StartColumn = 0 + && (List.last path).idRange.EndLine = keyword.StartLine + && firstDeclaration.Range.StartLine > keyword.StartLine + -> + ValueSome(Root(path, keyword, moduleRange)) + | _ -> ValueNone + + | _ -> ValueNone + + let isOnHeader (caretLine: int) shape = + match shape with + | Nested(namespaceKeyword = namespaceKeyword; moduleKeyword = moduleKeyword) -> + caretLine = namespaceKeyword.StartLine || caretLine = moduleKeyword.StartLine + | Root(keyword = keyword) -> caretLine = keyword.StartLine + + let dotted (idents: LongIdent) = + idents |> List.map _.idText |> String.concat "." + + let title shape = + match shape with + | Nested(namespacePath = namespacePath; moduleIdent = moduleIdent) -> + String.Format(SR.ConvertToRootModule(), $"{dotted namespacePath}.{moduleIdent.idText}") + | Root(path = path) -> + String.Format(SR.ConvertToNamespaceWithNestedModule(), dotted (List.take (path.Length - 1) path), (List.last path).idText) + + let linesInsideLiterals (parseTree: ParsedInput) = + (HashSet(), parseTree) + ||> ParsedInput.fold (fun lines _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Const(range = m)) + | SyntaxNode.SynExpr(SynExpr.InterpolatedString(range = m)) + | SyntaxNode.SynPat(SynPat.Const(range = m)) -> + for line in m.StartLine + 1 .. m.EndLine do + lines.Add(Line.toZ line) |> ignore + | _ -> () + + lines) + + let leadingSpaces (sourceText: SourceText) (line: TextLine) = + let mutable position = line.Start + + while position < line.End && sourceText[position] = ' ' do + position <- position + 1 + + position - line.Start + + let lineBreakOf (sourceText: SourceText) (line: TextLine) = + match line.EndIncludingLineBreak - line.End with + | 0 -> Environment.NewLine + | length -> sourceText.ToString(TextSpan(line.End, length)) + + let changes (sourceText: SourceText) (parseTree: ParsedInput) (indentSize: int) shape = + let lines = sourceText.Lines + let literalLines = linesInsideLiterals parseTree + + match shape with + | Nested( + namespacePath = namespacePath + namespaceIsRecursive = namespaceIsRecursive + namespaceKeyword = namespaceKeyword + opens = opens + moduleIdent = moduleIdent + moduleIsRecursive = moduleIsRecursive + equals = equals + bodyColumn = bodyColumn) -> + let namespaceLine = Line.toZ namespaceKeyword.StartLine + let headerLine = Line.toZ equals.EndLine + let lineBreak = lineBreakOf sourceText lines[headerLine] + + let openLines = + opens + |> List.collect (fun m -> [ Line.toZ m.StartLine .. Line.toZ m.EndLine ]) + |> Set.ofList + + // The first line after the namespace that is neither blank nor part of a moved `open`: a plain + // comment, an XML doc comment, an attribute, or (absent all of those) the module keyword itself. + // Everything before it — the namespace line, the opens, the blank lines around them — is deleted; + // everything from it onward stays exactly where it is. + let firstKeptLine = + seq { namespaceLine + 1 .. headerLine } + |> Seq.find (fun i -> + not (openLines.Contains i) + && not (String.IsNullOrWhiteSpace(lines[i].ToString()))) + + let moduleSpan = spanOf sourceText moduleIdent.idRange + let equalsEnd = (spanOf sourceText equals).End + + let headerEnd = + if isBlank sourceText equalsEnd lines[headerLine].End then + lines[headerLine].End + else + equalsEnd + + let recursive = + if namespaceIsRecursive && not moduleIsRecursive then + "rec " + else + "" + + let rootPath = + $"{recursive}{textBetween sourceText namespacePath.Head (List.last namespacePath)}.{sourceText.ToString moduleSpan}" + + [ + // The namespace line and every `open` before the module's own doc/attributes move to right after + // the header: a root-style `module A.B.C` must be the file's first declaration, so nothing — not + // even a leading `open` — can precede it. + TextChange(TextSpan.FromBounds(lines[namespaceLine].Start, lines[firstKeptLine].Start), "") + TextChange(TextSpan.FromBounds(moduleSpan.Start, headerEnd), rootPath) + + if not (List.isEmpty opens) then + let openText = + opens + |> List.map (fun m -> sourceText.ToString(spanOf sourceText m)) + |> String.concat lineBreak + + TextChange(TextSpan(lines[headerLine].EndIncludingLineBreak, 0), $"{lineBreak}{openText}{lineBreak}{lineBreak}") + + for i in headerLine + 1 .. lines.Count - 1 do + let removed = min bodyColumn (leadingSpaces sourceText lines[i]) + + if removed > 0 && not (literalLines.Contains i) then + TextChange(TextSpan(lines[i].Start, removed), "") + ] + + | Root(path, keyword, moduleRange) -> + let lastIdent = List.last path + let namespaceIdents = List.take (path.Length - 1) path + let lineBreak = lineBreakOf sourceText lines[Line.toZ keyword.StartLine] + let lastSpan = spanOf sourceText lastIdent.idRange + let padding = String(' ', indentSize) + + [ + TextChange( + TextSpan(lines[Line.toZ moduleRange.StartLine].Start, 0), + $"namespace {textBetween sourceText path.Head (List.last namespaceIdents)}{lineBreak}{lineBreak}" + ) + TextChange( + TextSpan.FromBounds((spanOf sourceText path.Head.idRange).Start, lastSpan.End), + $"{sourceText.ToString lastSpan} =" + ) + + for i in Line.toZ keyword.StartLine + 1 .. lines.Count - 1 do + if not (String.IsNullOrWhiteSpace(lines[i].ToString()) || literalLines.Contains i) then + TextChange(TextSpan(lines[i].Start, 0), padding) + ] + +[] +type internal FSharpConvertNamespaceModuleRefactoring [] () = + inherit CodeRefactoringProvider() + + static let hasSignatureFile (document: Document) = + let signaturePath = document.FilePath + "i" + + document.Project.Documents + |> Seq.exists (fun d -> String.Equals(d.FilePath, signaturePath, StringComparison.OrdinalIgnoreCase)) + + override _.ComputeRefactoringsAsync context = + cancellableTask { + let document = context.Document + + if not (document.IsFSharpSignatureFile || document.IsFSharpScript) then + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync cancellationToken + let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpConvertNamespaceModuleRefactoring) + + let caretLine = + Line.fromZ (sourceText.Lines.GetLineFromPosition context.Span.Start).LineNumber + + match NamespaceModuleConversion.tryShape sourceText parseResults.ParseTree with + | ValueSome shape when + NamespaceModuleConversion.isOnHeader caretLine shape + && not (hasSignatureFile document) + -> + let title = NamespaceModuleConversion.title shape + + let changedDocument = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! options = document.GetOptionsAsync cancellationToken + + let indentSize = + options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + + TelemetryReporter.ReportSingleEvent( + TelemetryEvents.RefactoringActivated, + [| "name", box (nameof FSharpConvertNamespaceModuleRefactoring) |] + ) + + let changes = + NamespaceModuleConversion.changes sourceText parseResults.ParseTree indentSize shape + + return document.WithText(sourceText.WithChanges changes) + } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | _ -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs index 230f4988438..a5bf5e6b4ef 100644 --- a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs +++ b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs @@ -18,6 +18,9 @@ module TelemetryEvents = [] let CodefixActivated = "codefixactivated" + [] + let RefactoringActivated = "refactoringactivated" + [] let Hints = "hints" diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..631fb0a2105 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -90,11 +90,21 @@ Navrhnout názvy pro nerozpoznané identifikátory; Převést na anonymní záznam + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Pro kontrolu nerovnosti použijte <>. + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Pro kontrolu rovnosti použijte =. diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index bce1941f0b1..acfc82e475f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -90,11 +90,21 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; In anonymen Datensatz konvertieren + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check "<>" für die Überprüfung auf Ungleichheit verwenden + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check "=" für Gleichheitsüberprüfung verwenden diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index fa8cb62c422..833256024fe 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -90,11 +90,21 @@ Sugerir nombres para identificadores sin resolver; Convertir en registro anónimo + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Usar "<>" para la comprobación de desigualdad + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Usar "=" para la comprobación de igualdad diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index e7ec71e839e..cf7767bbf68 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -90,11 +90,21 @@ Suggérer des noms pour les identificateurs non résolus ; Convertir en enregistrement anonyme + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Utiliser '<>' pour vérifier l'inégalité + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Utiliser '=' pour vérifier l'égalité diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 327a7ca362f..584de05fd36 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -90,11 +90,21 @@ Suggerisci i nomi per gli identificatori non risolti; Converti in record anonimo + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Usare '<>' per il controllo di disuguaglianza + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Usare '=' per il controllo di uguaglianza diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index d45234c011a..23f5583fa68 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -90,11 +90,21 @@ Suggest names for unresolved identifiers; 匿名レコードに変換 + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check 非等値のチェックには '<>' を使用します + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check 等値性のチェックには '=' を使用します diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 3248e0641ea..84c85ae096a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -90,11 +90,21 @@ Suggest names for unresolved identifiers; 익명 레코드로 변환 + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check 같지 않음 검사에 '<>' 사용 + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check 같음 검사에 '=' 사용 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index abc39f15da5..fdd7115f934 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -90,11 +90,21 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Konwertuj na rekord anonimowy + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Użyj operatora „<>” do sprawdzenia nierówności + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Użyj znaku „=” w celu sprawdzenia równości diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index dfde43120f5..9aaaab84236 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -90,11 +90,21 @@ Sugerir nomes para identificadores não resolvidos; Converter em Registro Anônimo + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Usar '<>' para a verificação de desigualdade + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Usar '=' para verificação de igualdade diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 47cda215312..930f1928d3e 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -90,11 +90,21 @@ Suggest names for unresolved identifiers; Преобразовать в анонимную запись + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Используйте "<>" для проверки на неравенство + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Используйте "=" для проверки равенства diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 58aa5d54c43..d39eb549563 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -90,11 +90,21 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Anonim Kayda Dönüştür + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check Eşitsizlik denetimi için '<>' kullanın + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check Eşitlik denetimi için '=' kullan diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 4fa703776fb..f79959235af 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -90,11 +90,21 @@ Suggest names for unresolved identifiers; 转换为匿名记录 + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check 使用 "<>" 进行不相等检查 + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check 使用 "=" 进行同等性检查 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index fd46ef9919a..48aa72e3a20 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -90,11 +90,21 @@ Suggest names for unresolved identifiers; 轉換為匿名記錄 + + Convert to 'namespace {0}' with nested module '{1}' + Convert to 'namespace {0}' with nested module '{1}' + + Use '<>' for inequality check 使用 '<>' 進行不等式檢查 + + Convert to 'module {0}' + Convert to 'module {0}' + + Use '=' for equality check 使用 '=' 檢查是否相等 diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..4210d42c07a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -74,6 +74,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs new file mode 100644 index 00000000000..557b2387fda --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs @@ -0,0 +1,104 @@ +module FSharp.Editor.Tests.Refactors.ConvertNamespaceModuleTests + +open System +open System.Threading + +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Text + +open Microsoft.VisualStudio.FSharp.Editor + +open Xunit + +open FSharp.Editor.Tests.Helpers +open FSharp.Editor.Tests.Refactors.RefactorTestFramework + +let private caretAt (code: string) (marker: string) = + code.IndexOf(marker, StringComparison.Ordinal) + +let private refactored (code: string) (marker: string) = + use context = TestContext.CreateWithCode code + + let document = + tryRefactor code (caretAt code marker) context (new FSharpConvertNamespaceModuleRefactoring()) + + (document.GetTextAsync() |> GetTaskResult).ToString() + +let private actionsAt (code: string) (marker: string) = + use context = TestContext.CreateWithCode code + tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertNamespaceModuleRefactoring()) + +let private nestedHelpers = + "namespace My.Company\n\n/// Utilities.\n[]\nmodule private Helpers =\n let inline twice x = x + x\n let banner = \"\"\"\n not\n touched\"\"\"\n" + +let private rootHelpers = + "/// Utilities.\n[]\nmodule private My.Company.Helpers\nlet inline twice x = x + x\nlet banner = \"\"\"\n not\n touched\"\"\"\n" + +[] +[] +[] +let ``Namespace with a single nested module converts to a root module`` (marker: string) = + Assert.Equal(rootHelpers, refactored nestedHelpers marker) + +[] +let ``Root module converts to a namespace with a nested module`` () = + Assert.Equal(nestedHelpers, refactored rootHelpers "module") + +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``Nested module converts to a root module`` (before: string, after: string) = + Assert.Equal(after, refactored before "namespace") + +[] +[] +[] +[] internal A.B.C\nlet x = 1\n", "namespace A.B\n\nmodule [] internal C =\n let x = 1\n")>] +[] +[] +let ``Root module converts to a nested module`` (before: string, after: string) = + Assert.Equal(after, refactored before "module") + +[] +let ``Converting to a nested module and back restores the root module`` () = + let original = "module A.B.C\n\nlet x = 1\n" + Assert.Equal(original, refactored (refactored original "module") "namespace") + +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) + +[] +let ``No action when the file has a signature`` () = + let code = "namespace A.B\n\nmodule C =\n let x = 1\n" + let signature = "namespace A.B\n\nmodule C =\n val x: int\n" + let document = RoslynTestHelpers.GetFsiAndFsDocuments signature code |> Seq.last + let actions = ResizeArray() + + let context = + CodeRefactoringContext(document, TextSpan(0, 1), (fun action -> actions.Add action), CancellationToken.None) + + (new FSharpConvertNamespaceModuleRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() + + Assert.False(document.IsFSharpSignatureFile) + Assert.Empty(actions) From 7f1b5899b465bd2c037bc609f703f91904e243c9 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:27:53 +0200 Subject: [PATCH 2/4] Write the refactoring test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ConvertNamespaceModuleTests.fs | 286 ++++++++++++++++-- 1 file changed, 257 insertions(+), 29 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs index 557b2387fda..f797c6cbb9d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs @@ -29,6 +29,7 @@ let private actionsAt (code: string) (marker: string) = use context = TestContext.CreateWithCode code tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertNamespaceModuleRefactoring()) +// Both contain a triple-quoted string, which a triple-quoted literal cannot hold. let private nestedHelpers = "namespace My.Company\n\n/// Utilities.\n[]\nmodule private Helpers =\n let inline twice x = x + x\n let banner = \"\"\"\n not\n touched\"\"\"\n" @@ -46,57 +47,284 @@ let ``Root module converts to a namespace with a nested module`` () = Assert.Equal(nestedHelpers, refactored rootHelpers "module") [] -[] -[] -[] -[] -[] +[] +[] +[] +[] +[] [] -[] -[] -[] +[] +[] +[] let ``Nested module converts to a root module`` (before: string, after: string) = Assert.Equal(after, refactored before "namespace") [] -[] -[] -[] internal A.B.C\nlet x = 1\n", "namespace A.B\n\nmodule [] internal C =\n let x = 1\n")>] -[] +[] +[] +[] internal A.B.C +let x = 1 +""", + """ +namespace A.B + +module [] internal C = + let x = 1 +""")>] +[] [] let ``Root module converts to a nested module`` (before: string, after: string) = Assert.Equal(after, refactored before "module") [] let ``Converting to a nested module and back restores the root module`` () = - let original = "module A.B.C\n\nlet x = 1\n" + let original = + """ +module A.B.C + +let x = 1 +""" + Assert.Equal(original, refactored (refactored original "module") "namespace") [] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] -[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) [] let ``No action when the file has a signature`` () = - let code = "namespace A.B\n\nmodule C =\n let x = 1\n" - let signature = "namespace A.B\n\nmodule C =\n val x: int\n" + let code = + """ +namespace A.B + +module C = + let x = 1 +""" + + let signature = + """ +namespace A.B + +module C = + val x: int +""" + let document = RoslynTestHelpers.GetFsiAndFsDocuments signature code |> Seq.last let actions = ResizeArray() let context = - CodeRefactoringContext(document, TextSpan(0, 1), (fun action -> actions.Add action), CancellationToken.None) + CodeRefactoringContext(document, TextSpan(caretAt code "namespace", 1), (fun action -> actions.Add action), CancellationToken.None) (new FSharpConvertNamespaceModuleRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() From 0a1122a1bb1a08bb8acc2c896561d22bf4429208 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 19:30:17 +0200 Subject: [PATCH 3/4] Remove the moved opens when a comment precedes them The namespace line and the opens were deleted as one block ending at the first kept line, so a comment between the namespace and the opens left the opens in place while copies were added under the new header. Each of them is now deleted on its own, with the blank lines after it. Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactor/ConvertNamespaceModule.fs | 40 +++++++++++++------ .../Refactors/ConvertNamespaceModuleTests.fs | 19 +++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs index 2c5e7610cac..639ac929ceb 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs @@ -189,15 +189,28 @@ module private NamespaceModuleConversion = |> List.collect (fun m -> [ Line.toZ m.StartLine .. Line.toZ m.EndLine ]) |> Set.ofList - // The first line after the namespace that is neither blank nor part of a moved `open`: a plain - // comment, an XML doc comment, an attribute, or (absent all of those) the module keyword itself. - // Everything before it — the namespace line, the opens, the blank lines around them — is deleted; - // everything from it onward stays exactly where it is. - let firstKeptLine = - seq { namespaceLine + 1 .. headerLine } - |> Seq.find (fun i -> - not (openLines.Contains i) - && not (String.IsNullOrWhiteSpace(lines[i].ToString()))) + // The namespace line and the moved `open`s are deleted, each with the blank lines right after it; + // comments, XML doc comments and attributes among them stay in front of the new header. + let deletedRanges = + let removedLines = openLines.Add namespaceLine + + let deletedLines = + ((false, []), [ namespaceLine .. headerLine - 1 ]) + ||> List.fold (fun (afterDeleted, deleted) i -> + if + removedLines.Contains i + || (afterDeleted && String.IsNullOrWhiteSpace(lines[i].ToString())) + then + true, i :: deleted + else + false, deleted) + |> snd + + ([], deletedLines) + ||> List.fold (fun ranges i -> + match ranges with + | struct (first, last) :: rest when first = i + 1 -> struct (i, last) :: rest + | _ -> struct (i, i) :: ranges) let moduleSpan = spanOf sourceText moduleIdent.idRange let equalsEnd = (spanOf sourceText equals).End @@ -218,10 +231,11 @@ module private NamespaceModuleConversion = $"{recursive}{textBetween sourceText namespacePath.Head (List.last namespacePath)}.{sourceText.ToString moduleSpan}" [ - // The namespace line and every `open` before the module's own doc/attributes move to right after - // the header: a root-style `module A.B.C` must be the file's first declaration, so nothing — not - // even a leading `open` — can precede it. - TextChange(TextSpan.FromBounds(lines[namespaceLine].Start, lines[firstKeptLine].Start), "") + // A root-style `module A.B.C` must be the file's first declaration, so the `open`s move to right + // after the header. + for struct (first, last) in deletedRanges do + TextChange(TextSpan.FromBounds(lines[first].Start, lines[last].EndIncludingLineBreak), "") + TextChange(TextSpan.FromBounds(moduleSpan.Start, headerEnd), rootPath) if not (List.isEmpty opens) then diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs index f797c6cbb9d..3db51cc4ba4 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs @@ -153,6 +153,25 @@ module A.B.C open System +let x = 1 +""")>] +[] let ``Nested module converts to a root module`` (before: string, after: string) = From afc31274ce8d1c091f8e2564aec762e35dbbb326 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:02 +0200 Subject: [PATCH 4/4] Link the release note to the pull request and move it to a random line of its section Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 070b6bd9306..fe59843a4fc 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,8 +1,8 @@ ### Added +* Refactoring to convert a file written as `namespace A.B` with a single nested `module C =` into a root-level `module A.B.C`, and a root-level `module A.B.C` back into `namespace A.B` with a nested `module C =`. ([PR #20536](https://github.com/dotnet/fsharp/pull/20536)) * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) -* Refactoring to convert a file written as `namespace A.B` with a single nested `module C =` into a root-level `module A.B.C`, and a root-level `module A.B.C` back into `namespace A.B` with a nested `module C =`. ### Fixed