diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index ba03f663967..fe59843a4fc 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -1,5 +1,6 @@
### 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))
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..639ac929ceb
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertNamespaceModule.fs
@@ -0,0 +1,329 @@
+// 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 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
+
+ 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}"
+
+ [
+ // 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
+ 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..3db51cc4ba4
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertNamespaceModuleTests.fs
@@ -0,0 +1,351 @@
+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())
+
+// 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"
+
+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
+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
+
+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
+
+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(caretAt code "namespace", 1), (fun action -> actions.Add action), CancellationToken.None)
+
+ (new FSharpConvertNamespaceModuleRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult()
+
+ Assert.False(document.IsFSharpSignatureFile)
+ Assert.Empty(actions)