diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 52bbf6f7bc5..96ab53cd5e8 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -3,6 +3,9 @@
* 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))
* Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485))
+* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given.
+* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521))
+* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 41 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521))
### Fixed
diff --git a/vsintegration/Vsix/RegisterFsharpPackage.pkgdef b/vsintegration/Vsix/RegisterFsharpPackage.pkgdef
index 4c0558508ea..a11811a6589 100644
--- a/vsintegration/Vsix/RegisterFsharpPackage.pkgdef
+++ b/vsintegration/Vsix/RegisterFsharpPackage.pkgdef
@@ -154,6 +154,25 @@
"RequestStockColors"=dword:00000001
@="{35a5e6b8-4012-41fc-a652-2cdc56d74e9f}"
+; Code snippets. The default value is the GUID FSharpSnippetExpansionClient passes to
+; InvokeInsertionUI and GetExpansionByShortcut, "LangStringID" has to match every snippet's
+; , and "DisplayName" resolves against the package named below.
+; Only 1033 ships, so the paths name it outright: registering both %LCID% and a literal 1033
+; would enumerate every snippet twice on an English VS.
+[$RootKey$\Languages\CodeExpansions\FSharp]
+@="{bc6dd5a5-d4d6-4dab-a00d-a51242dbaf1b}"
+"Package"="{871d2a70-12a2-4e42-9440-425dd92a4116}"
+"DisplayName"="#100"
+"LangStringID"="FSharp"
+"IndexPath"="$PackageFolder$\Snippets\1033\SnippetsIndex.xml"
+"ShowRoots"=dword:00000000
+
+[$RootKey$\Languages\CodeExpansions\FSharp\Paths]
+"Visual F#"="$PackageFolder$\Snippets\1033\FSharp\;%MyDocs%\Code Snippets\Visual F#\My Code Snippets\"
+
+[$RootKey$\Languages\CodeExpansions\FSharp\ForceCreateDirs]
+"Visual F#"="%MyDocs%\Code Snippets\Visual F#\My Code Snippets\"
+
[$RootKey$\FontAndColors\FSharpInteractive]
"Category"="{00CCEE86-3140-4E06-A65A-A92665A40D6F}"
"Package"="{F5E7E71D-1401-11D1-883B-0000F87579D2}"
diff --git a/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets b/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets
index db4b3097d66..cc5ee9edc52 100644
--- a/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets
+++ b/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets
@@ -22,6 +22,20 @@
License.txt
true
+
+
+
+ Snippets\1033
+ true
+
+
+
+
+ Snippets\1033\FSharp
+ true
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/abstract.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/abstract.snippet
new file mode 100644
index 00000000000..58ec8d7d16f
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/abstract.snippet
@@ -0,0 +1,35 @@
+
+
+
+
+ abstract
+ abstract
+ Code snippet for an abstract member
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Member name
+ Member
+
+
+ type
+ Parameter type
+ int
+
+
+ returnType
+ Return type
+ unit
+
+
+ $returnType$
+$end$]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/async.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/async.snippet
new file mode 100644
index 00000000000..24be865bbfe
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/async.snippet
@@ -0,0 +1,20 @@
+
+
+
+
+ async
+ async
+ Code snippet for an async expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/attribute.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/attribute.snippet
new file mode 100644
index 00000000000..cd830500806
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/attribute.snippet
@@ -0,0 +1,26 @@
+
+
+
+
+ attribute
+ attribute
+ Code snippet for a custom attribute
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Attribute name, without the Attribute suffix
+ My
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet
new file mode 100644
index 00000000000..5f6c39d5b88
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet
@@ -0,0 +1,20 @@
+
+
+
+
+ backgroundTask
+ backgroundTask
+ Code snippet for a backgroundTask expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/class.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/class.snippet
new file mode 100644
index 00000000000..31496198c3e
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/class.snippet
@@ -0,0 +1,30 @@
+
+
+
+
+ class
+ class
+ Code snippet for a class type
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Class name
+ MyClass
+
+
+ member
+ Member name
+ Method
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ctor.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ctor.snippet
new file mode 100644
index 00000000000..d9631197724
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ctor.snippet
@@ -0,0 +1,36 @@
+
+
+
+
+ ctor
+ ctor
+ Code snippet for an additional constructor
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ arguments
+ Constructor arguments
+ arg: int
+
+
+ classname
+ Class name
+ ClassName()
+ ClassNamePlaceholder
+
+
+ values
+ Arguments passed to the primary constructor
+ arg
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/dispose.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/dispose.snippet
new file mode 100644
index 00000000000..ab3598fc4c6
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/dispose.snippet
@@ -0,0 +1,19 @@
+
+
+
+
+ dispose
+ dispose
+ Code snippet for an IDisposable implementation
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/du.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/du.snippet
new file mode 100644
index 00000000000..9508588ef6f
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/du.snippet
@@ -0,0 +1,42 @@
+
+
+
+
+ du
+ du
+ Code snippet for a discriminated union type
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Union name
+ MyUnion
+
+
+ case1
+ First case
+ Case1
+
+
+ case2
+ Second case
+ Case2
+
+
+ type
+ Field type of the second case
+ int
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/enum.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/enum.snippet
new file mode 100644
index 00000000000..5d95e7db68e
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/enum.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ enum
+ enum
+ Code snippet for an enum type
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Enum name
+ MyEnum
+
+
+ case
+ First value
+ Value
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/equals.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/equals.snippet
new file mode 100644
index 00000000000..87074856aed
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/equals.snippet
@@ -0,0 +1,30 @@
+
+
+
+
+ equals
+ equals
+ Code snippet for Equals and GetHashCode overrides
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ classname
+ Class name
+ ClassName()
+ ClassNamePlaceholder
+
+
+ $end$
+ | _ -> false
+
+override this.GetHashCode() = 0]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/exn.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/exn.snippet
new file mode 100644
index 00000000000..4a07dad6285
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/exn.snippet
@@ -0,0 +1,30 @@
+
+
+
+
+ exn
+ exn
+ Code snippet for an exception declaration
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Exception name
+ MyException
+
+
+ type
+ Carried data
+ string
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ext.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ext.snippet
new file mode 100644
index 00000000000..6992d006e20
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ext.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ ext
+ ext
+ Code snippet for a type extension
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ type
+ Type to extend
+ System.String
+
+
+ member
+ Member name
+ Member
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/for.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/for.snippet
new file mode 100644
index 00000000000..f867253f916
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/for.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ for
+ for
+ Code snippet for a for loop over a sequence
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ item
+ Iteration variable
+ item
+
+
+ collection
+ Sequence to iterate
+ collection
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/forr.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/forr.snippet
new file mode 100644
index 00000000000..71a212ba74b
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/forr.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ forr
+ forr
+ Code snippet for a for loop over a range
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ index
+ Index variable
+ i
+
+
+ max
+ Exclusive upper bound
+ length
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/if.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/if.snippet
new file mode 100644
index 00000000000..e41be8565ea
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/if.snippet
@@ -0,0 +1,26 @@
+
+
+
+
+ if
+ if
+ Code snippet for an if expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ condition
+ Condition to test
+ true
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/iface.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/iface.snippet
new file mode 100644
index 00000000000..3d58e7d25f7
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/iface.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ iface
+ iface
+ Code snippet for an interface implementation
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ interface
+ Interface to implement
+ IMyInterface
+
+
+ member
+ Member name
+ Member
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ife.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ife.snippet
new file mode 100644
index 00000000000..24b2e8e17a5
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ife.snippet
@@ -0,0 +1,28 @@
+
+
+
+
+ ife
+ ife
+ Code snippet for an if/else expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ condition
+ Condition to test
+ true
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/interface.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/interface.snippet
new file mode 100644
index 00000000000..bbe0bd0d60c
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/interface.snippet
@@ -0,0 +1,36 @@
+
+
+
+
+ interface
+ interface
+ Code snippet for an interface type
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Interface name
+ IMyInterface
+
+
+ member
+ Member name
+ Member
+
+
+ type
+ Member type
+ int -> unit
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet
new file mode 100644
index 00000000000..f03960500bc
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet
@@ -0,0 +1,27 @@
+
+
+
+
+ lock
+ lock
+ Code snippet for a lock
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ lockObject
+ Object to lock on
+ lockObject
+
+
+
+ $selected$$end$
+)]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/main.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/main.snippet
new file mode 100644
index 00000000000..810f4b65aa1
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/main.snippet
@@ -0,0 +1,20 @@
+
+
+
+
+ main
+ main
+ Code snippet for a program entry point
+ Microsoft Corporation
+
+ Expansion
+
+
+
+ ]
+let main argv =
+ $end$
+ 0]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/match.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/match.snippet
new file mode 100644
index 00000000000..2e0c6d7fc82
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/match.snippet
@@ -0,0 +1,32 @@
+
+
+
+
+ match
+ match
+ Code snippet for a match expression
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ expression
+ Expression to match on
+ expression
+
+
+ cases
+ Cases
+ GenerateMatchCases($expression$)
+ | _ -> ()
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcho.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcho.snippet
new file mode 100644
index 00000000000..2d50f64c521
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcho.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ matcho
+ matcho
+ Code snippet for a match expression over an option
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ expression
+ Option to match on
+ expression
+
+
+ value
+ Name bound to the carried value
+ value
+
+
+ $end$
+| None -> ()]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchr.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchr.snippet
new file mode 100644
index 00000000000..b0024ba29b4
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchr.snippet
@@ -0,0 +1,36 @@
+
+
+
+
+ matchr
+ matchr
+ Code snippet for a match expression over a result
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ expression
+ Result to match on
+ expression
+
+
+ value
+ Name bound to the success value
+ value
+
+
+ error
+ Name bound to the error value
+ error
+
+
+ $end$
+| Error $error$ -> ()]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcht.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcht.snippet
new file mode 100644
index 00000000000..70708dc92ef
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcht.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ matcht
+ matcht
+ Code snippet for a match expression over a Try... call returning an out parameter
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ expression
+ Call whose out parameter becomes the second element
+ dictionary.TryGetValue key
+
+
+ value
+ Name bound to the out parameter
+ value
+
+
+ $end$
+| false, _ -> ()]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchvo.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchvo.snippet
new file mode 100644
index 00000000000..a5692dbc180
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchvo.snippet
@@ -0,0 +1,31 @@
+
+
+
+
+ matchvo
+ matchvo
+ Code snippet for a match expression over a voption
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ expression
+ Value option to match on
+ expression
+
+
+ value
+ Name bound to the carried value
+ value
+
+
+ $end$
+| ValueNone -> ()]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/member.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/member.snippet
new file mode 100644
index 00000000000..61e1045583f
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/member.snippet
@@ -0,0 +1,25 @@
+
+
+
+
+ member
+ member
+ Code snippet for a member method
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Member name
+ Method
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/module.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/module.snippet
new file mode 100644
index 00000000000..5b3b4ffdc6a
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/module.snippet
@@ -0,0 +1,26 @@
+
+
+
+
+ module
+ module
+ Code snippet for a module declaration
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Module name
+ MyModule
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/nowarn.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/nowarn.snippet
new file mode 100644
index 00000000000..98d0d8a4add
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/nowarn.snippet
@@ -0,0 +1,27 @@
+
+
+
+
+ nowarn
+ nowarn
+ Code snippet for a scoped #nowarn
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ code
+ Warning number to suppress
+ 0040
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/override.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/override.snippet
new file mode 100644
index 00000000000..389cb2c66ec
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/override.snippet
@@ -0,0 +1,25 @@
+
+
+
+
+ override
+ override
+ Code snippet for an overridden member
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Member name
+ ToString
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pfn.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pfn.snippet
new file mode 100644
index 00000000000..90f708627bd
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pfn.snippet
@@ -0,0 +1,25 @@
+
+
+
+
+ pfn
+ pfn
+ Code snippet for printfn
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ text
+ Text to print
+ message
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pp_if.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pp_if.snippet
new file mode 100644
index 00000000000..c18054fbfe7
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pp_if.snippet
@@ -0,0 +1,27 @@
+
+
+
+
+ #if
+ #if
+ Code snippet for #if
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ symbol
+ Conditional compilation symbol
+ DEBUG
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/prop.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/prop.snippet
new file mode 100644
index 00000000000..8ca954812ee
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/prop.snippet
@@ -0,0 +1,30 @@
+
+
+
+
+ prop
+ prop
+ Code snippet for an auto-implemented property
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Property name
+ MyProperty
+
+
+ value
+ Initial value
+ 0
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/propfull.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/propfull.snippet
new file mode 100644
index 00000000000..47128ecd597
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/propfull.snippet
@@ -0,0 +1,39 @@
+
+
+
+
+ propfull
+ propfull
+ Code snippet for a property and its backing field
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ field
+ The mutable value backing this property
+ myField
+
+
+ value
+ Initial value
+ 0
+
+
+ property
+ Property name
+ MyProperty
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/record.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/record.snippet
new file mode 100644
index 00000000000..c8b45f8ca83
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/record.snippet
@@ -0,0 +1,36 @@
+
+
+
+
+ record
+ record
+ Code snippet for a record type
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Record name
+ MyRecord
+
+
+ field
+ Field name
+ Field
+
+
+ type
+ Field type
+ int
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/seq.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/seq.snippet
new file mode 100644
index 00000000000..e458b106611
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/seq.snippet
@@ -0,0 +1,20 @@
+
+
+
+
+ seq
+ seq
+ Code snippet for a sequence expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/struct.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/struct.snippet
new file mode 100644
index 00000000000..90dab1e79ec
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/struct.snippet
@@ -0,0 +1,37 @@
+
+
+
+
+ struct
+ struct
+ Code snippet for a struct record
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Struct name
+ MyStruct
+
+
+ field
+ Field name
+ Field
+
+
+ type
+ Field type
+ int
+
+
+ ]
+type $name$ =
+ { $field$: $type$ }
+$end$]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/task.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/task.snippet
new file mode 100644
index 00000000000..b36b997e211
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/task.snippet
@@ -0,0 +1,20 @@
+
+
+
+
+ task
+ task
+ Code snippet for a task expression
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/try.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/try.snippet
new file mode 100644
index 00000000000..e9ec25ec7e5
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/try.snippet
@@ -0,0 +1,21 @@
+
+
+
+
+ try
+ try
+ Code snippet for try/with
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+ reraise ()]]>
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/tryf.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/tryf.snippet
new file mode 100644
index 00000000000..1c03d350417
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/tryf.snippet
@@ -0,0 +1,21 @@
+
+
+
+
+ tryf
+ tryf
+ Code snippet for try/finally
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/use.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/use.snippet
new file mode 100644
index 00000000000..e632f22bc23
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/use.snippet
@@ -0,0 +1,30 @@
+
+
+
+
+ use
+ use
+ Code snippet for a use binding
+ Microsoft Corporation
+
+ Expansion
+
+
+
+
+
+ name
+ Name bound to the resource
+ resource
+
+
+ expression
+ Expression producing the resource
+ resource
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/while.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/while.snippet
new file mode 100644
index 00000000000..a37fce4b7e9
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/while.snippet
@@ -0,0 +1,26 @@
+
+
+
+
+ while
+ while
+ Code snippet for a while loop
+ Microsoft Corporation
+
+ Expansion
+ SurroundsWith
+
+
+
+
+
+ condition
+ Condition to test
+ true
+
+
+
+
+
+
diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/SnippetsIndex.xml b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/SnippetsIndex.xml
new file mode 100644
index 00000000000..c35e01da41c
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/SnippetsIndex.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ On
+ true
+ 1033
+ $PackageFolder$\Snippets\1033\FSharp\
+ Visual F#
+
+
+
diff --git a/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs b/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs
index feeb3b10722..e49abce9fb1 100644
--- a/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs
+++ b/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs
@@ -94,27 +94,6 @@ type internal AddMissingAttributeToSignatureCodeFixProvider [ 0 then
- Some(sigSourceText.ToString(TextSpan(line.End, lbLen)))
- else
- None
-
- let lines = sigSourceText.Lines
- let startLineNo = lines.GetLineFromPosition(lineStart).LineNumber
- let mutable result: string option = None
- let mutable i = startLineNo
-
- while result.IsNone && i >= 0 do
- result <- lineBreakOf lines.[i]
- i <- i - 1
-
- result |> Option.defaultValue Environment.NewLine
-
// Returns None if the .fsi was truncated between registration and apply.
let tryFSharpRangeToTextSpan (text: SourceText) (range: FSharp.Compiler.Text.range) =
try
@@ -204,7 +183,7 @@ type internal AddMissingAttributeToSignatureCodeFixProvider [
let currentLineStart = current.Lines.GetLineFromPosition(currentSigSpan.Start).Start
let currentIndent = indentOfLine current currentLineStart
- let currentLineBreak = lineBreakAt current currentLineStart
+ let currentLineBreak = current.LineBreakAt currentLineStart
let currentInsertion = $"{currentIndent}{bracketed}{currentLineBreak}"
let updated =
diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs
index 79ad4e213a0..5b7e120e94a 100644
--- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs
+++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs
@@ -296,6 +296,15 @@ type SourceText with
member this.ToFSharpSourceText() =
SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create))
+ member this.LineBreakAt(position: int) =
+ let rec lineBreakOf (line: TextLine) =
+ match this.ToString(TextSpan(line.End, line.EndIncludingLineBreak - line.End)) with
+ | "" when line.LineNumber > 0 -> lineBreakOf this.Lines[line.LineNumber - 1]
+ | "" -> Environment.NewLine
+ | lineBreak -> lineBreak
+
+ lineBreakOf (this.Lines.GetLineFromPosition position)
+
type NavigationItem with
member x.RoslynGlyph: FSharpRoslynGlyph =
diff --git a/vsintegration/src/FSharp.Editor/Common/Vs.fs b/vsintegration/src/FSharp.Editor/Common/Vs.fs
index 175025923eb..4bd00effc64 100644
--- a/vsintegration/src/FSharp.Editor/Common/Vs.fs
+++ b/vsintegration/src/FSharp.Editor/Common/Vs.fs
@@ -97,6 +97,14 @@ module internal ServiceProviderExtensions =
member sp.TextManager = sp.GetService()
+ member sp.ExpansionManager =
+ match sp.GetService() with
+ | null -> null
+ | textManager ->
+ match textManager.GetExpansionManager() with
+ | hr, expansionManager when Com.Succeeded hr -> expansionManager
+ | _ -> null
+
member sp.RunningDocumentTable =
sp.GetService()
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 319bdd5a264..d3187b4c377 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -101,6 +101,10 @@
+
+
+
+
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
index 1f1f632d770..e1d25e34489 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:
+
+ Insert Snippet
+
+
+ Surround With
+
\ No newline at end of file
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpEditorFactory.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpEditorFactory.fs
index 48d373df713..5bfef4b42c2 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpEditorFactory.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpEditorFactory.fs
@@ -29,6 +29,9 @@ module Constants =
[]
let FSharpAnalysisSaveFileHandler = "FSharp Analysis Save File Handler"
+ []
+ let FSharpSnippetsCommandHandler = "FSharp Snippets Command Handler"
+
[]
type FSharpEditorFactory(parentPackage: ShellPackage) =
diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs
new file mode 100644
index 00000000000..8fb08cda3dd
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System.ComponentModel.Composition
+
+open Microsoft.CodeAnalysis
+
+open Microsoft.VisualStudio.Commanding
+open Microsoft.VisualStudio.Editor
+open Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion
+open Microsoft.VisualStudio.Text
+open Microsoft.VisualStudio.Text.Editor
+open Microsoft.VisualStudio.Text.Editor.Commanding.Commands
+open Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods
+open Microsoft.VisualStudio.Utilities
+
+open CancellableTasks
+
+[]
+module internal SnippetCommandHelpers =
+
+ []
+ let private userOpName = "FSharpSnippetCommandHandler"
+
+ /// Going through the lexer is what keeps Tab from expanding a word typed inside a string or a
+ /// comment, and `FullIsland` is what keeps it from expanding the member name in `value.for`.
+ let tryGetShortcutAt (document: Document) position =
+ cancellableTask {
+ let! symbol = document.TryFindFSharpLexerSymbolAsync(position, SymbolLookupKind.Greedy, false, false, userOpName)
+
+ return
+ match symbol with
+ | Some symbol when symbol.FullIsland.Length = 1 -> ValueSome(symbol.Ident.idText, symbol.Ident.idRange)
+ | _ -> ValueNone
+ }
+
+ /// Selecting whole lines ends the selection at column 0 of the following one, so `$selected$`
+ /// receives that line break too: whatever the snippet places after the field - `#endif`, `else`,
+ /// a closing `}` - lands on the line that followed the selection instead of on its own.
+ let trimSelectedLineBreak (textView: ITextView) =
+ let selection = textView.Selection
+ let span = selection.StreamSelectionSpan.SnapshotSpan
+ let endLine = span.Snapshot.GetLineFromPosition span.End.Position
+
+ if span.End.Position = endLine.Start.Position && span.Length > 0 then
+ let trimmed =
+ SnapshotSpan(span.Start, span.Snapshot.GetLineFromLineNumber(endLine.LineNumber - 1).End)
+
+ // Caret first: moving it collapses the selection.
+ textView.Caret.MoveTo trimmed.End |> ignore
+ textView.Selection.Select(trimmed, selection.IsReversed)
+
+ /// The column the wrapped code sits at and how many lines it covers. The insertion replaces the
+ /// selection, so neither survives it and the expansion client is told up front.
+ let selectionShape (textView: ITextView) =
+ let span = textView.Selection.StreamSelectionSpan.SnapshotSpan
+ let snapshot = span.Snapshot
+ let firstLine = snapshot.GetLineFromPosition(span.Start.Position).LineNumber
+ let lastLine = snapshot.GetLineFromPosition(span.End.Position).LineNumber
+
+ let lines =
+ [
+ for lineNumber in firstLine..lastLine -> snapshot.GetLineFromLineNumber(lineNumber).GetText()
+ ]
+
+ let column =
+ SnippetIndentation.classify (textView.Options.GetTabSize()) (ValueSome(0, lines.Length - 1)) lines
+ |> List.filter (fun line -> line.Kind.IsSelectedFirst || line.Kind.IsSelectedRest)
+ |> List.map _.Indent
+ |> function
+ | [] -> 0
+ | indents -> List.min indents
+
+ column, lines.Length
+
+/// Ordered after the completion handler so that Tab still commits an open completion list first.
+[)>]
+[]
+[]
+[]
+type internal FSharpSnippetCommandHandler [] (editorAdapters: IVsEditorAdaptersFactoryService) =
+
+ let tryGetClient (textView: ITextView) (subjectBuffer: ITextBuffer) =
+ match textView with
+ | :? IWpfTextView as wpfTextView ->
+ ValueSome(
+ wpfTextView.Properties.GetOrCreateSingletonProperty(fun () ->
+ FSharpSnippetExpansionClient(wpfTextView, subjectBuffer, editorAdapters))
+ )
+ | _ -> ValueNone
+
+ let tryGetSessionClient (textView: ITextView) (subjectBuffer: ITextBuffer) =
+ tryGetClient textView subjectBuffer |> ValueOption.filter _.IsInSession
+
+ let tryExpandShortcut (args: TabKeyCommandArgs) (client: FSharpSnippetExpansionClient) =
+ let caret = args.TextView.Caret.Position.BufferPosition.Position
+
+ tryGetDocument args.SubjectBuffer
+ |> ValueOption.bind (fun document -> runSynchronously parseTimeout (tryGetShortcutAt document caret))
+ |> ValueOption.exists (fun (shortcut, range) ->
+ let shortcutSpan =
+ VsTextSpan(
+ iStartLine = range.StartLine - 1,
+ iStartIndex = range.StartColumn,
+ iEndLine = range.EndLine - 1,
+ iEndIndex = range.EndColumn
+ )
+
+ client.TryInsertExpansionForShortcut(shortcut, shortcutSpan))
+
+ interface INamed with
+ member _.DisplayName = Constants.FSharpSnippetsCommandHandler
+
+ interface ICommandHandler with
+ member _.GetCommandState _ = CommandState.Available
+
+ member _.ExecuteCommand(args, _) =
+ tryGetClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (fun client -> client.TryInsertSnippet())
+
+ interface ICommandHandler with
+ member _.GetCommandState args =
+ if args.TextView.Selection.IsEmpty then
+ CommandState.Unavailable
+ else
+ CommandState.Available
+
+ // The buffer is left alone: the expansion engine reads the selection off the view to fill `$selected$`.
+ member _.ExecuteCommand(args, _) =
+ trimSelectedLineBreak args.TextView
+ let column, lineCount = selectionShape args.TextView
+
+ tryGetClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (fun client -> client.TrySurroundWith(column, lineCount))
+
+ interface ICommandHandler with
+ member _.GetCommandState _ = CommandState.Unspecified
+
+ member _.ExecuteCommand(args, _) =
+ match tryGetSessionClient args.TextView args.SubjectBuffer with
+ | ValueSome client -> client.TryHandleTab()
+ | ValueNone ->
+ args.TextView.Selection.IsEmpty
+ && (tryGetClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (tryExpandShortcut args))
+
+ interface ICommandHandler with
+ member _.GetCommandState _ = CommandState.Unspecified
+
+ member _.ExecuteCommand(args, _) =
+ tryGetSessionClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (fun client -> client.TryHandleBackTab())
+
+ interface ICommandHandler with
+ member _.GetCommandState _ = CommandState.Unspecified
+
+ member _.ExecuteCommand(args, _) =
+ tryGetSessionClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (fun client -> client.TryHandleReturn())
+
+ interface ICommandHandler with
+ member _.GetCommandState _ = CommandState.Unspecified
+
+ member _.ExecuteCommand(args, _) =
+ tryGetSessionClient args.TextView args.SubjectBuffer
+ |> ValueOption.exists (fun client -> client.TryHandleEscape())
diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs
new file mode 100644
index 00000000000..3f1fdf6c420
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs
@@ -0,0 +1,312 @@
+// 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.Xml.Linq
+
+open Microsoft.VisualStudio
+open Microsoft.VisualStudio.Editor
+open Microsoft.VisualStudio.FSharp.Editor.DebugHelpers
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.Text
+open Microsoft.VisualStudio.Text.Editor
+open Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods
+open Microsoft.VisualStudio.TextManager.Interop
+
+open MSXML
+
+[]
+module internal SnippetExpansionHelpers =
+
+ let leadingWhitespaceOf (line: ITextSnapshotLine) =
+ let snapshot = line.Snapshot
+ let start = line.Start.Position
+ let mutable width = 0
+
+ while width < line.Length && Char.IsWhiteSpace snapshot[start + width] do
+ width <- width + 1
+
+ width
+
+ let indentTextOf (options: IEditorOptions) width =
+ if options.IsConvertTabsToSpacesEnabled() then
+ String(' ', width)
+ else
+ let tabSize = options.GetTabSize()
+ String('\t', width / tabSize) + String(' ', width % tabSize)
+
+ /// Where `$selected$` sits in a snippet's ``: which of its lines holds the field, and the
+ /// column the template indents it to. The expansion session will not report it, so it is read
+ /// from the file the picker named.
+ let tryReadSelectedFieldLayout (path: string) =
+ try
+ XDocument.Load(path).Descendants()
+ |> Seq.filter (fun element -> element.Name.LocalName = "Code")
+ |> Seq.map _.Value
+ |> Seq.tryHeadV
+ |> ValueOption.bind (fun (code: string) ->
+ code.Replace("\r\n", "\n").Split('\n')
+ |> Seq.indexed
+ |> Seq.tryPickV (fun (index, line: string) ->
+ match line.IndexOf("$selected$", StringComparison.Ordinal) with
+ | -1 -> ValueNone
+ | column -> ValueSome(index, column)))
+ with e ->
+ FSharpOutputPane.logException e
+ ValueNone
+
+ let tryParseFunctionCall (call: string) =
+ match call.IndexOf('(') with
+ | -1 -> ValueNone
+ | openParen when call.EndsWith(")", StringComparison.Ordinal) ->
+ let name = call.Substring(0, openParen).Trim()
+
+ let arguments =
+ call.Substring(openParen + 1, call.Length - openParen - 2).Split(',')
+ |> Array.map _.Trim()
+ |> Array.filter (fun argument -> argument.Length > 0)
+
+ if name.Length = 0 then
+ ValueNone
+ else
+ ValueSome(name, arguments)
+ | _ -> ValueNone
+
+/// Everything Surround With needs to put the result back at the right column, none of which the
+/// insertion can be asked for afterwards.
+type internal SurroundLayout =
+ {
+ /// The column the wrapped code sat at.
+ Column: int
+ /// How many lines it covered.
+ LineCount: int
+ FieldLine: int
+ FieldIndent: int
+ }
+
+type internal FSharpSnippetExpansionClient
+ (textView: IWpfTextView, subjectBuffer: ITextBuffer, editorAdapters: IVsEditorAdaptersFactoryService) =
+
+ let languageGuid = Guid FSharpConstants.languageServiceGuidString
+
+ /// Set from `OnBeforeInsertion` rather than from `InsertNamedExpansion`'s out parameter: a snippet
+ /// with no editable fields ends its session from inside that call, so the out parameter arrives
+ /// after `EndExpansion` has already run.
+ let mutable expansionSession: IVsExpansionSession = null
+
+ /// What the open picker was invoked over, before the template is known: `(column, lineCount)`
+ /// for Surround With, ValueNone for Insert Snippet.
+ let mutable pendingSurround: (int * int) voption = ValueNone
+
+ /// The same, completed with the chosen template's layout once the picker has answered. ValueNone
+ /// for Insert Snippet, where the caret column is the whole answer.
+ let mutable surround: SurroundLayout voption = ValueNone
+
+ /// `FormatSpan` can be called more than once per session, and it inserts, so it must run once.
+ let mutable indentPending = false
+
+ member _.IsInSession =
+ match expansionSession with
+ | null -> false
+ | _ -> true
+
+ /// `tsInsertPos` is the range `InsertNamedExpansion` *replaces*, so handing it the selection
+ /// deletes the text a SurroundsWith snippet was meant to wrap. The engine reads the selection off
+ /// the view it was given in `InvokeInsertionUI` to fill `$selected$`.
+ member private _.TryGetCaretSpan() =
+ if not (obj.ReferenceEquals(textView.TextBuffer, subjectBuffer)) then
+ // Nothing projects F# today; bail out rather than guess at a mapping.
+ ValueNone
+ else
+ let caret = textView.Caret.Position.BufferPosition
+ let line = caret.Snapshot.GetLineFromPosition caret.Position
+ let column = caret.Position - line.Start.Position
+
+ ValueSome(VsTextSpan(iStartLine = line.LineNumber, iStartIndex = column, iEndLine = line.LineNumber, iEndIndex = column))
+
+ member private this.InsertNamedExpansion(title, path, insertionSpan: VsTextSpan, selection) =
+ match editorAdapters.GetBufferAdapter subjectBuffer with
+ | :? IVsExpansion as expansion ->
+ surround <-
+ match selection, tryReadSelectedFieldLayout path with
+ | ValueSome(column, lineCount), ValueSome(fieldLine, fieldIndent) ->
+ ValueSome
+ {
+ Column = column
+ LineCount = lineCount
+ FieldLine = fieldLine
+ FieldIndent = fieldIndent
+ }
+ | _ -> ValueNone
+
+ indentPending <- true
+
+ let hr, _session =
+ expansion.InsertNamedExpansion(title, path, insertionSpan, this, languageGuid, 0)
+
+ ErrorHandler.Succeeded hr
+ | _ -> false
+
+ member this.TryInsertExpansionForShortcut(shortcut: string, shortcutSpan: VsTextSpan) =
+ match ServiceProvider.GlobalProvider.ExpansionManager, editorAdapters.GetViewAdapter textView with
+ | null, _
+ | _, null -> false
+ | expansionManager, viewAdapter ->
+ let spans = [| shortcutSpan |]
+
+ match expansionManager.GetExpansionByShortcut(this, languageGuid, shortcut, viewAdapter, spans, 0) with
+ | _, null, _ -> false
+ | hr, path, title ->
+ ErrorHandler.Succeeded hr
+ && this.InsertNamedExpansion(title, path, spans[0], ValueNone)
+
+ /// It is not modal: the chosen item comes back later through `OnItemChosen`.
+ member private this.InvokeInsertionUI(types: string[], prompt, selection) =
+ match ServiceProvider.GlobalProvider.ExpansionManager, editorAdapters.GetViewAdapter textView with
+ | null, _
+ | _, null -> false
+ | expansionManager, viewAdapter ->
+ pendingSurround <- selection
+
+ let hr =
+ expansionManager.InvokeInsertionUI(viewAdapter, this, languageGuid, types, types.Length, 1, null, 0, 0, prompt, null)
+
+ not (ErrorHandler.Failed hr)
+
+ member this.TryInsertSnippet() =
+ this.InvokeInsertionUI([| "Expansion"; "SurroundsWith" |], SR.InsertSnippet(), ValueNone)
+
+ member this.TrySurroundWith(column: int, lineCount: int) =
+ this.InvokeInsertionUI([| "SurroundsWith" |], SR.SurroundWith(), ValueSome(column, lineCount))
+
+ member private _.EndSession(leaveCaret) =
+ match expansionSession with
+ | null -> ()
+ | session ->
+ session.EndCurrentExpansion leaveCaret |> ignore
+ expansionSession <- null
+
+ member this.TryHandleTab() =
+ match expansionSession with
+ | null -> false
+ | session ->
+ // Navigation wraps around, so a failure means the session is no longer usable.
+ if not (Com.Succeeded(session.GoToNextExpansionField 0)) then
+ this.EndSession 0
+
+ true
+
+ member this.TryHandleBackTab() =
+ match expansionSession with
+ | null -> false
+ | session ->
+ if not (Com.Succeeded(session.GoToPreviousExpansionField())) then
+ this.EndSession 0
+
+ true
+
+ member this.TryHandleReturn() =
+ if this.IsInSession then
+ this.EndSession 0
+ true
+ else
+ false
+
+ member this.TryHandleEscape() =
+ if this.IsInSession then
+ this.EndSession 1
+ true
+ else
+ false
+
+ member private _.Reindent(span: VsTextSpan) =
+ let snapshot = subjectBuffer.CurrentSnapshot
+ let tabSize = textView.Options.GetTabSize()
+
+ let lines =
+ [
+ for lineNumber in span.iStartLine .. min span.iEndLine (snapshot.LineCount - 1) -> snapshot.GetLineFromLineNumber lineNumber
+ ]
+
+ let texts = lines |> List.map _.GetText()
+
+ // `GetFieldSpan "selected"` does not answer for that special literal.
+ let placement, selectedLines =
+ match surround with
+ | ValueSome layout ->
+ SnippetIndentation.AroundSelection(layout.Column, layout.FieldIndent),
+ ValueSome(layout.FieldLine, layout.FieldLine + layout.LineCount - 1)
+ | ValueNone ->
+ let caretColumn =
+ texts.Head
+ |> Seq.take span.iStartIndex
+ |> Seq.fold (SnippetIndentation.advanceColumn tabSize) 0
+
+ SnippetIndentation.AtCaret caretColumn, ValueNone
+
+ use edit = subjectBuffer.CreateEdit()
+
+ SnippetIndentation.classify tabSize selectedLines texts
+ |> SnippetIndentation.deltas placement
+ |> List.iter2
+ (fun (line: ITextSnapshotLine) delta ->
+ match delta with
+ | 0 -> ()
+ | indent when indent > 0 -> edit.Insert(line.Start.Position, indentTextOf textView.Options indent) |> ignore
+ // A negative delta unindents the line entirely, and the indent is measured
+ // in columns while the edit removes characters.
+ | _ -> edit.Delete(line.Start.Position, leadingWhitespaceOf line) |> ignore)
+ lines
+
+ edit.Apply() |> ignore
+
+ interface IVsExpansionClient with
+
+ member _.IsValidType(_buffer, _ts, _rgTypes, _iCountTypes, pfIsValidType: byref) =
+ pfIsValidType <- 1
+ VSConstants.S_OK
+
+ member _.IsValidKind(_buffer, _ts, _bstrKind, pfIsValidKind: byref) =
+ pfIsValidKind <- 1
+ VSConstants.S_OK
+
+ member _.OnBeforeInsertion(session) =
+ expansionSession <- session
+ VSConstants.S_OK
+
+ member _.OnAfterInsertion _session = VSConstants.S_OK
+
+ member _.PositionCaretForEditing(_buffer, _ts) = VSConstants.S_OK
+
+ member _.EndExpansion() =
+ expansionSession <- null
+ VSConstants.S_OK
+
+ member this.OnItemChosen(pszTitle, pszPath) =
+ match this.TryGetCaretSpan() with
+ | ValueSome span -> this.InsertNamedExpansion(pszTitle, pszPath, span, pendingSurround) |> ignore
+ | ValueNone -> ()
+
+ VSConstants.S_OK
+
+ member this.GetExpansionFunction(xmlFunctionNode: IXMLDOMNode, _bstrFieldName, pFunc: byref) =
+ let getSession = fun () -> expansionSession
+
+ match tryParseFunctionCall xmlFunctionNode.text with
+ | ValueSome("ClassName", arguments) ->
+ pFunc <- SnippetFunctionClassName(getSession, subjectBuffer, arguments)
+ VSConstants.S_OK
+ | ValueSome("GenerateMatchCases", arguments) ->
+ pFunc <- SnippetFunctionGenerateMatchCases(getSession, subjectBuffer, arguments)
+ VSConstants.S_OK
+ | _ ->
+ pFunc <- null
+ VSConstants.E_INVALIDARG
+
+ member this.FormatSpan(_buffer, ts: VsTextSpan[]) =
+ if indentPending && ts.Length > 0 then
+ indentPending <- false
+ this.Reindent(ts[0])
+
+ VSConstants.S_OK
diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs
new file mode 100644
index 00000000000..4e15aa9b04b
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs
@@ -0,0 +1,235 @@
+// 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.Threading
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Text
+open Microsoft.VisualStudio
+open Microsoft.VisualStudio.FSharp.Editor.DebugHelpers
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.Text
+open Microsoft.VisualStudio.TextManager.Interop
+
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.Symbols
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+
+open CancellableTasks
+
+type internal VsTextSpan = Microsoft.VisualStudio.TextManager.Interop.TextSpan
+
+[]
+module internal SnippetFunctionHelpers =
+
+ []
+ let private userOpName = "FSharpSnippetFunction"
+
+ /// Long enough for a warm parse, short enough that a cold project degrades instead of hanging.
+ []
+ let parseTimeout = 2000
+
+ let private tryGetSpan (session: IVsExpansionSession) (getSpan: IVsExpansionSession -> VsTextSpan[] -> int) =
+ match session with
+ | null -> ValueNone
+ | session ->
+ let spans = Array.zeroCreate 1
+
+ if Com.Succeeded(getSpan session spans) then
+ ValueSome spans[0]
+ else
+ ValueNone
+
+ let tryGetSnippetSpan session =
+ tryGetSpan session (fun session spans -> session.GetSnippetSpan spans)
+
+ let tryGetFieldSpan session field =
+ tryGetSpan session (fun session spans -> session.GetFieldSpan(field, spans))
+
+ /// The expansion engine calls `IVsExpansionFunction` synchronously on the UI thread while the
+ /// session is live, so there is nowhere to await.
+ let runSynchronously millisecondsTimeout (work: CancellableTask<'T voption>) =
+ use cts = new CancellationTokenSource(millisecondsTimeout: int)
+
+ try
+ ThreadHelper.JoinableTaskFactory.Run(fun () -> work cts.Token)
+ with
+ | :? OperationCanceledException when cts.IsCancellationRequested -> ValueNone
+ // This runs inside a COM callback, so an exception that escapes unwinds into native Visual
+ // Studio code.
+ | e ->
+ FSharpOutputPane.logException e
+ ValueNone
+
+ let tryGetDocument (subjectBuffer: ITextBuffer) =
+ match subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges() with
+ | null -> ValueNone
+ | document when document.Project.IsFSharp -> ValueSome document
+ | _ -> ValueNone
+
+ let tryGetContainingTypeName (document: Document) pos =
+ cancellableTask {
+ let! parseResults = document.GetFSharpParseResultsAsync userOpName
+
+ return
+ (pos, parseResults.ParseTree)
+ ||> ParsedInput.tryPickLast (fun _ node ->
+ match node with
+ // The walk offers every type of a `type … and …` group, and the nearest declaration
+ // to the left when none contains `pos`.
+ | SyntaxNode.SynTypeDefn(SynTypeDefn(typeInfo = typeInfo; range = m))
+ | SyntaxNode.SynTypeDefnSig(SynTypeDefnSig(typeInfo = typeInfo; range = m)) when Range.rangeContainsPos m pos ->
+ typeInfo.LongIdent |> List.tryLast |> Option.map _.idText
+ | _ -> None)
+ |> ValueOption.ofOption
+ }
+
+ let private necessaryQualifier (checkResults: FSharpCheckFileResults) position (entity: FSharpEntity) (symbol: FSharpSymbol) =
+ let path =
+ match entity.TryGetFullDisplayName() with
+ | Some fullName -> List.ofArray (fullName.Split '.')
+ | None -> [ entity.DisplayName ]
+
+ let rec widen remaining qualifier =
+ if checkResults.IsRelativeNameResolvableFromSymbol(position, qualifier, symbol) then
+ qualifier
+ else
+ match remaining with
+ | [] -> qualifier
+ | next :: rest -> widen rest (next :: qualifier)
+
+ widen (List.rev path) []
+
+ let private qualifierPrefix checkResults position entity symbol =
+ match necessaryQualifier checkResults position entity symbol with
+ | [] -> ""
+ | qualifier -> String.Join(".", qualifier) + "."
+
+ let private matchRulesFor checkResults position (entity: FSharpEntity) =
+ if entity.IsFSharpUnion then
+ let prefix =
+ match Seq.tryHeadV entity.UnionCases with
+ | ValueSome first -> qualifierPrefix checkResults position entity first
+ | ValueNone -> ""
+
+ entity.UnionCases
+ |> Seq.map (fun case ->
+ if case.HasFields then
+ $"| %s{prefix}%s{case.DisplayName} _ -> ()"
+ else
+ $"| %s{prefix}%s{case.DisplayName} -> ()")
+ elif entity.IsEnum then
+ let literals =
+ entity.FSharpFields |> Seq.filter (fun field -> field.LiteralValue.IsSome)
+
+ let prefix =
+ match Seq.tryHeadV literals with
+ | ValueSome first -> qualifierPrefix checkResults position entity first
+ | ValueNone -> ""
+
+ seq {
+ for field in literals do
+ $"| %s{prefix}%s{field.DisplayName} -> ()"
+
+ // An enum value need not be one of the declared literals, so the wildcard is not optional.
+ "| _ -> ()"
+ }
+ else
+ Seq.empty
+
+ let tryGetMatchRulesAt (document: Document) (range: range) =
+ cancellableTask {
+ let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName
+ let! ct = CancellableTask.getCancellationToken ()
+ let! sourceText = document.GetTextAsync ct
+
+ let rules =
+ match checkResults.TryGetCapturedType range |> Option.map _.StripAbbreviations() with
+ | Some fsharpType when fsharpType.HasTypeDefinition -> matchRulesFor checkResults range.Start fsharpType.TypeDefinition
+ | _ -> Seq.empty
+
+ let lineBreak =
+ sourceText.LineBreakAt(RoslynHelpers.FSharpRangeToTextSpan(sourceText, range).End)
+
+ return
+ match String.Join(lineBreak, rules) with
+ | "" -> ValueNone
+ | rules -> ValueSome rules
+ }
+
+ let tryGetMatchRules (document: Document) (span: VsTextSpan) =
+ Range.mkRange document.FilePath (Position.fromZ span.iStartLine span.iStartIndex) (Position.fromZ span.iEndLine span.iEndIndex)
+ |> tryGetMatchRulesAt document
+
+[]
+type internal FSharpSnippetFunction(getSession: unit -> IVsExpansionSession, arguments: string[]) =
+
+ /// The engine can build a function before it opens the session, so this is read per call.
+ member _.Session = getSession ()
+
+ abstract TryGetValue: unit -> string voption
+
+ interface IVsExpansionFunction with
+
+ member _.GetFunctionType(pFuncType: byref) =
+ pFuncType <- uint _ExpansionFunctionType.eft_Value
+ VSConstants.S_OK
+
+ member _.GetListCount(iCount: byref) =
+ iCount <- 0
+ VSConstants.S_OK
+
+ member _.GetListText(_index, pbstrText: byref) =
+ pbstrText <- null
+ VSConstants.E_NOTIMPL
+
+ member this.GetDefaultValue(bstrValue: byref, fHasDefaultValue: byref) =
+ match this.TryGetValue() with
+ | ValueSome value ->
+ bstrValue <- value
+ fHasDefaultValue <- 1
+ | ValueNone ->
+ bstrValue <- ""
+ fHasDefaultValue <- 0
+
+ VSConstants.S_OK
+
+ member this.GetCurrentValue(bstrValue: byref, fHasCurrentValue: byref) =
+ (this :> IVsExpansionFunction).GetDefaultValue(&bstrValue, &fHasCurrentValue)
+
+ member _.FieldChanged(bstrField: string, fRequeryFunction: byref) =
+ fRequeryFunction <-
+ if arguments |> Array.contains $"$%s{bstrField}$" then
+ 1
+ else
+ 0
+
+ VSConstants.S_OK
+
+ member _.ReleaseFunction() = VSConstants.S_OK
+
+type internal SnippetFunctionClassName(getSession, subjectBuffer: ITextBuffer, arguments) =
+ inherit FSharpSnippetFunction(getSession, arguments)
+
+ override this.TryGetValue() =
+ match tryGetDocument subjectBuffer, tryGetSnippetSpan this.Session with
+ | ValueSome document, ValueSome span ->
+ runSynchronously parseTimeout (tryGetContainingTypeName document (Position.fromZ span.iStartLine span.iStartIndex))
+ | _ -> ValueNone
+
+type internal SnippetFunctionGenerateMatchCases(getSession, subjectBuffer: ITextBuffer, arguments: string[]) =
+ inherit FSharpSnippetFunction(getSession, arguments)
+
+ let matchedField =
+ match arguments with
+ | [| argument |] when argument.StartsWith("$", StringComparison.Ordinal) -> ValueSome(argument.Trim '$')
+ | _ -> ValueNone
+
+ override this.TryGetValue() =
+ match tryGetDocument subjectBuffer, matchedField |> ValueOption.bind (tryGetFieldSpan this.Session) with
+ | ValueSome document, ValueSome span ->
+ runSynchronously document.Project.FSharpTimeUntilStaleCompletion (tryGetMatchRules document span)
+ | _ -> ValueNone
diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs
new file mode 100644
index 00000000000..9324c406c76
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs
@@ -0,0 +1,114 @@
+// 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 FSharp.Compiler.Tokenization
+
+/// The expansion engine inserts a snippet verbatim: the opening line lands at the insertion column
+/// and every later line at the column its template spells, with the text substituted into
+/// `$selected$` carrying whatever indentation it had in the buffer. C# survives that because Roslyn's
+/// formatter reflows the result; F# has no formatter, so the columns are computed here instead.
+module internal SnippetIndentation =
+
+ type LineKind =
+ /// The snippet's own text. Takes the column of the code it wraps.
+ | Template
+ /// A compiler directive, which reads at the left margin whatever it wraps.
+ | RootLevelDirective
+ /// The first line of the text substituted into `$selected$`. The template already placed it.
+ | SelectedFirst
+ /// A later line of that text. It starts its own buffer line at its original column.
+ | SelectedRest
+ /// A later line of that text which begins inside a string literal continued from an earlier
+ /// selected line - its whitespace is part of the string's value, not its layout.
+ | InsideString
+ /// Whitespace only; left alone so the snippet does not leave trailing spaces behind.
+ | Blank
+
+ /// `Indent` is a visual column, so a tab counts as the width it renders at.
+ type Line = { Kind: LineKind; Indent: int }
+
+ type Placement =
+ /// Insert Snippet. The caret already positioned the opening line; the rest follow it.
+ | AtCaret of column: int
+ /// Surround With over a whole-line selection, so the insertion began at column 0.
+ /// `column` is the column the wrapped block sat at, `fieldIndent` the template's own
+ /// indentation around `$selected$` - the one nesting level the wrapper contributes.
+ | AroundSelection of column: int * fieldIndent: int
+
+ let advanceColumn tabSize column character =
+ if character = '\t' then
+ column + tabSize - column % tabSize
+ else
+ column + 1
+
+ /// The public list of lexer states is missing some of the string ones, so the state is asked how it reads code.
+ let private isInsideString (tokenizer: FSharpSourceTokenizer) lexState =
+ match (tokenizer.CreateLineTokenizer "x").ScanToken lexState with
+ | Some token, _ -> token.ColorClass = FSharpTokenColorKind.String
+ | None, _ -> false
+
+ let rec private leadingTokenAndEndState (tokenizer: FSharpLineTokenizer) lexState leadingToken =
+ match tokenizer.ScanToken lexState, leadingToken with
+ | (None, endState), _ -> struct (leadingToken, endState)
+ | (Some token, afterToken), ValueNone when token.ColorClass <> FSharpTokenColorKind.Default ->
+ leadingTokenAndEndState tokenizer afterToken (ValueSome token)
+ | (Some _, afterToken), _ -> leadingTokenAndEndState tokenizer afterToken leadingToken
+
+ let private originOf selectedLines index =
+ match selectedLines with
+ | ValueSome(first, _) when index = first -> SelectedFirst
+ | ValueSome(first, last) when index > first && index <= last -> SelectedRest
+ | _ -> Template
+
+ let private classifyLine (tokenizer: FSharpSourceTokenizer) tabSize origin lexState (text: string) =
+ let struct (leadingToken, endState) =
+ leadingTokenAndEndState (tokenizer.CreateLineTokenizer text) lexState ValueNone
+
+ let kind =
+ match leadingToken with
+ | _ when String.IsNullOrWhiteSpace text -> Blank
+ | _ when isInsideString tokenizer lexState -> InsideString
+ | ValueSome token when token.ColorClass = FSharpTokenColorKind.PreprocessorKeyword -> RootLevelDirective
+ | _ -> origin
+
+ {
+ Kind = kind
+ Indent = text |> Seq.takeWhile Char.IsWhiteSpace |> Seq.fold (advanceColumn tabSize) 0
+ },
+ endState
+
+ /// `selectedLines` are the first and last index filled from `$selected$`. Those lines are lexed apart from the
+ /// snippet's own, so a directive or string one of them leaves open does not reach into the other.
+ let classify tabSize selectedLines (lines: string list) =
+ let tokenizer = FSharpSourceTokenizer([], None, None)
+
+ lines
+ |> List.indexed
+ |> List.mapFold
+ (fun struct (template, selection) (index, text) ->
+ match originOf selectedLines index with
+ | Template ->
+ let line, template = classifyLine tokenizer tabSize Template template text
+ line, struct (template, selection)
+ | selected ->
+ let line, selection = classifyLine tokenizer tabSize selected selection text
+ line, struct (template, selection))
+ struct (FSharpTokenizerLexState.Initial, FSharpTokenizerLexState.Initial)
+ |> fst
+
+ /// How far each line has to move. Positive inserts, negative removes, zero leaves it alone.
+ let deltas placement (lines: Line list) =
+ lines
+ |> List.mapi (fun index line ->
+ match line.Kind, placement with
+ | Blank, _
+ | InsideString, _ -> 0
+ | RootLevelDirective, _ -> -line.Indent
+ | Template, AtCaret column -> if index = 0 then 0 else column
+ | Template, AroundSelection(column, _) -> column
+ | SelectedFirst, _ -> 0
+ | SelectedRest, AroundSelection(_, fieldIndent) -> fieldIndent
+ | SelectedRest, AtCaret _ -> 0)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
index cd8c46bf705..26748637fd8 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
@@ -140,6 +140,11 @@ Navrhnout názvy pro nerozpoznané identifikátory;
Implementujte rozhraní bez anotace typu.
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Zobrazit poznámky v Rychlých informacích
Odeberte nepoužité otevřené deklarace.
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Neočekávaný symbol „=“ v deklaraci pole. Očekával se token „:“ nebo nějaký jiný.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
index bce1941f0b1..0d8a13b411c 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
@@ -140,6 +140,11 @@ Namen für nicht aufgelöste Bezeichner vorschlagen;
Schnittstelle ohne Typanmerkung implementieren
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Hinweise in QuickInfo anzeigen
Nicht verwendete open-Deklarationen entfernen
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Unerwartetes Symbol "=" in der Felddeklaration. Erwartet wurde ":" oder ein anderes Token.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
index fa8cb62c422..0cd5afc39ad 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
@@ -140,6 +140,11 @@ Sugerir nombres para identificadores sin resolver;
Implementar interfaz sin anotación de tipos
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Mostrar comentarios en Información rápida
Quitar declaraciones abiertas no usadas
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Símbolo inesperado “=” en la declaración de campo. Se esperaba “:” u otro token.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
index e7ec71e839e..18acbab6f2e 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
@@ -140,6 +140,11 @@ Suggérer des noms pour les identificateurs non résolus ;
Implémenter l'interface sans annotation de type
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Afficher les notes dans Info express
Supprimer les déclarations open inutilisées
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Symbole inattendu '=' dans la déclaration de champ. ':' attendu ou autre jeton.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
index 327a7ca362f..050c3c36bac 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
@@ -140,6 +140,11 @@ Suggerisci i nomi per gli identificatori non risolti;
Implementa l'interfaccia senza annotazione di tipo
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Mostra i commenti in Informazioni rapide
Rimuovi dichiarazioni OPEN inutilizzate
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Simbolo "=" imprevisto nella dichiarazione di campo. Previsto ":" o altro token.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
index d45234c011a..f5f2e85b30c 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
@@ -140,6 +140,11 @@ Suggest names for unresolved identifiers;
型の注釈を指定しないでインターフェイスを実装する
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ F# 構文規則に準拠した改行を追加して、署名を指定された
未使用の Open 宣言を削除する
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.フィールド宣言で予期しないシンボル '=' が発生しました。':' またはその他のトークンが必要です。
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
index 3248e0641ea..46c95ddd4e6 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
@@ -140,6 +140,11 @@ Suggest names for unresolved identifiers;
형식 주석 없이 인터페이스 구현
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그
사용하지 않는 열려 있는 선언 제거
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.필드 선언에 예기치 않은 기호 '='가 있습니다. ':' 또는 다른 토큰이 필요합니다.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
index abc39f15da5..c0878fb7d5e 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
@@ -140,6 +140,11 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów;
Zaimplementuj interfejs bez adnotacji typu
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Pokaż uwagi w szybkich informacjach
Usuń nieużywane otwarte deklaracje
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Nieoczekiwany symbol „=” w deklaracji pola. Oczekiwano znaku „:” lub innego tokenu.
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..00d41f64e9b 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
@@ -140,6 +140,11 @@ Sugerir nomes para identificadores não resolvidos;
Implementar a interface sem a anotação de tipo
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Mostrar os comentários nas Informações Rápidas
Remover declarações abertas não usadas
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Símbolo inesperado "=" na declaração de campo. "." ou outro token é esperado.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
index 47cda215312..f2a30c42a40 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
@@ -140,6 +140,11 @@ Suggest names for unresolved identifiers;
Реализовать интерфейс без заметки с типом
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Show remarks in Quick InfoУдалить неиспользуемые открытые объявления
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Непредвиденный символ "=" в объявлении поля. Требуется ":" или другая лексема.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
index 58aa5d54c43..e53efddcb4d 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
@@ -140,6 +140,11 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner;
Tür ek açıklaması olmadan arabirim uygulama
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Açıklamaları Hızlı Bilgide göster
Kullanılmayan açık bildirimleri kaldır
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.Alan bildiriminde beklenmeyen '=' sembolü. ':' veya başka bir belirteç bekleniyordu.
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..7a4f19b6128 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
@@ -140,6 +140,11 @@ Suggest names for unresolved identifiers;
无类型批注的实现接口
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Show remarks in Quick Info删除未使用的 open 声明
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.字段声明中出现意外的符号 "="。应为 ":" 或其他标记。
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..b3bcff04e8a 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
@@ -140,6 +140,11 @@ Suggest names for unresolved identifiers;
實作沒有類型註釋的介面
+
+ Insert Snippet
+ Insert Snippet
+
+ Completion Lists;
Show completion list after a character is deleted;
@@ -293,6 +298,11 @@ Show remarks in Quick Info移除未使用的公開宣告
+
+ Surround With
+ Surround With
+
+ Unexpected symbol '=' in field declaration. Expected ':' or other token.欄位宣告中有未預期的符號 '='。必須是 ':' 或其他語彙基元。
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index ecce1205b8c..3b5b6297b4b 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -85,6 +85,18 @@
+
+
+
+
+
+
+
+
+ Snippets\%(RecursiveDir)%(Filename)%(Extension)
+ PreserveNewest
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs
new file mode 100644
index 00000000000..0c86e2cbfe2
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module FSharp.Editor.Tests.SnippetCatalogTests
+
+open System
+open System.IO
+open System.Text.RegularExpressions
+open System.Xml.Linq
+
+open Xunit
+
+open Microsoft.VisualStudio.FSharp.Editor
+
+open FSharp.Compiler.Diagnostics
+open FSharp.Test
+
+type private Snippet =
+ {
+ Title: string
+ Shortcut: string
+ Types: string list
+ Literals: (string * string) list
+ Code: string
+ }
+
+ member this.IsSurroundsWith = List.contains "SurroundsWith" this.Types
+
+let private xmlns =
+ XNamespace.Get "http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet"
+
+let private catalog = Path.Combine(AppContext.BaseDirectory, "Snippets", "1033")
+let private indexPath = Path.Combine(catalog, "SnippetsIndex.xml")
+
+let private names =
+ Directory.GetFiles(Path.Combine(catalog, "FSharp"), "*.snippet")
+ |> Array.map Path.GetFileNameWithoutExtension
+ |> Array.sort
+
+let private pathOf name =
+ Path.Combine(catalog, "FSharp", $"%s{name}.snippet")
+
+let private load name =
+ let snippet =
+ XDocument.Load(pathOf name).Descendants(xmlns + "CodeSnippet") |> Seq.exactlyOne
+
+ let header = snippet.Element(xmlns + "Header")
+ let body = snippet.Element(xmlns + "Snippet")
+
+ {
+ Title = header.Element(xmlns + "Title").Value
+ Shortcut = header.Element(xmlns + "Shortcut").Value
+ Types = [ for element in header.Descendants(xmlns + "SnippetType") -> element.Value ]
+ Literals =
+ [
+ for literal in body.Descendants(xmlns + "Literal") ->
+ literal.Element(xmlns + "ID").Value, literal.Element(xmlns + "Default").Value
+ ]
+ Code = body.Element(xmlns + "Code").Value
+ }
+
+let private mentionsSelected (text: string) =
+ text.IndexOf("$selected$", StringComparison.Ordinal) >= 0
+
+/// `$end$` always sits in expression position, so `do ()` stands in for it.
+let private expand snippet =
+ snippet.Literals
+ |> List.fold (fun (code: string) (id, dflt) -> code.Replace($"$%s{id}$", dflt)) snippet.Code
+ |> _.Replace("$selected$", "").Replace("$end$", "do ()").Replace("$$", "$")
+
+let private indented (code: string) = " " + code.Replace("\n", "\n ")
+
+/// A body is a fragment, so it only parses inside the right kind of host.
+let private hosts =
+ [
+ "whole file", id
+ "module level", (fun code -> $"module TestHost\n\n%s{code}\n")
+ "type body", (fun code -> $"module TestHost\n\ntype Host() =\n%s{indented code}\n")
+ "function body", (fun code -> $"module TestHost\n\nlet f () =\n%s{indented code}\n")
+ ]
+
+let private parseErrors source =
+ CompilerAssert.Parse(source, fileName = "Test.fs")
+ |> _.Diagnostics
+ |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error)
+
+let snippetNames: obj[][] = [| for name in names -> [| name |] |]
+
+[]
+let ``The catalog ships the snippets the registration promises`` () =
+ Assert.Equal(41, names.Length)
+ Assert.True(File.Exists indexPath, $"missing {indexPath}")
+
+[]
+let ``Shortcuts and titles are unique`` () =
+ let snippets = names |> Array.map load
+
+ let duplicatesBy key =
+ snippets |> Seq.countBy key |> Seq.filter (fun (_, count) -> count > 1)
+
+ Assert.Empty(duplicatesBy _.Shortcut)
+ Assert.Empty(duplicatesBy _.Title)
+
+[]
+let ``Snippet is an Expansion under its own name, authored at column zero with spaces`` (name: string) =
+ let snippet = load name
+ // `pp_if` follows C#, which cannot name a file `#if`.
+ let shortcut = if name = "pp_if" then "#if" else name
+
+ Assert.Contains("Expansion", snippet.Types)
+ Assert.Equal(shortcut, snippet.Shortcut)
+ Assert.Equal(snippet.Shortcut, snippet.Title)
+ Assert.DoesNotContain("\t", snippet.Code)
+ Assert.False(snippet.Code.StartsWith(" ", StringComparison.Ordinal), "body must start at column 0")
+
+[]
+let ``Snippet literals are all declared and all used`` (name: string) =
+ let snippet = load name
+
+ let referenced =
+ Regex.Matches(snippet.Code, @"\$([A-Za-z][A-Za-z0-9]*)\$")
+ |> Seq.cast
+ |> Seq.map _.Groups[1].Value
+ |> Seq.filter (fun id -> id <> "end" && id <> "selected")
+ |> Set.ofSeq
+
+ Assert.Equal>(snippet.Literals |> List.map fst |> Set.ofList, referenced)
+
+[]
+let ``Snippet marks the caret and a surround field the expansion client can read back`` (name: string) =
+ let snippet = load name
+ let layout = tryReadSelectedFieldLayout (pathOf name)
+
+ Assert.Contains("$end$", snippet.Code)
+ Assert.Equal(snippet.IsSurroundsWith, mentionsSelected snippet.Code)
+
+ if snippet.IsSurroundsWith then
+ let lines = snippet.Code.Replace("\r\n", "\n").Split '\n'
+ let fieldLine = lines |> Array.findIndex mentionsSelected
+ let field = lines[fieldLine]
+
+ // The engine indents wrapped text by the field's column, so nothing but whitespace may precede it.
+ Assert.Equal(ValueSome(fieldLine, field.Length - field.TrimStart().Length), layout)
+ else
+ Assert.Equal(ValueNone, layout)
+
+[]
+let ``Snippet expands to F# that parses`` (name: string) =
+ let code = expand (load name)
+ let errorsByHost = [ for host, wrap in hosts -> host, parseErrors (wrap code) ]
+
+ if errorsByHost |> List.forall (snd >> Array.isEmpty >> not) then
+ let firstErrors =
+ errorsByHost
+ |> List.map (fun (host, errors) -> $"%s{host}: %s{errors[0].Message}")
+ |> String.concat "; "
+
+ Assert.Fail($"%s{name} does not parse in any host: %s{firstErrors}\n---\n%s{code}")
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs
new file mode 100644
index 00000000000..9dc59e0545d
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs
@@ -0,0 +1,140 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module FSharp.Editor.Tests.SnippetFunctionTests
+
+open System
+
+open Xunit
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Text
+open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+
+open FSharp.Editor.Tests.Helpers
+
+let private rangeOf (document: Document) (source: string) span =
+ RoslynHelpers.TextSpanToFSharpRange(document.FilePath, span, SourceText.From source)
+
+/// The innermost type's name at `Marked`, or "" when it is not inside a type.
+let private containingTypeName (source: string) =
+ let document = RoslynTestHelpers.GetFsDocument source
+ let marked = TextSpan(source.IndexOf("Marked", StringComparison.Ordinal), 0)
+
+ tryGetContainingTypeName document (rangeOf document source marked).Start
+ |> CancellableTask.runSynchronouslyWithoutCancellation
+ |> ValueOption.defaultValue ""
+
+/// The rules generated for the expression matched on, one per line, or "" when there are none.
+let private matchRules (context: string) (expression: string) =
+ let prefix = $"{context}\nlet run () =\n match "
+ let source = $"{prefix}{expression} with\n | _ -> 0\n"
+ let document = RoslynTestHelpers.GetFsDocument source
+
+ tryGetMatchRulesAt document (rangeOf document source (TextSpan(prefix.Length, expression.Length)))
+ |> CancellableTask.runSynchronouslyWithoutCancellation
+ |> ValueOption.map _.Replace("\r\n", "\n")
+ |> ValueOption.defaultValue ""
+
+let typeNames: obj[][] =
+ [|
+ [| "a type at the top level"; "type C() =\n member _.Marked = 0"; "C" |]
+ [|
+ "a type in a module"
+ "module Outer =\n type C() =\n member _.Marked = 0"
+ "C"
+ |]
+ [|
+ "a type in nested modules"
+ "module A =\n module B =\n type C() =\n member _.Marked = 0"
+ "C"
+ |]
+ [|
+ "the type the position is in, of two"
+ "type First() =\n member _.Value = 0\n\ntype Second() =\n member _.Marked = 0"
+ "Second"
+ |]
+ [|
+ "the type the position is in, of a recursive group"
+ "type First() =\n member _.Marked = 0\n\nand Second = | A"
+ "First"
+ |]
+ [| "no type outside a type"; "let Marked = 1"; "" |]
+ |]
+
+let matchCases: obj[][] =
+ [|
+ [|
+ "a union in scope"
+ "type U = A | B\nlet value = U.B"
+ "value"
+ "| A -> ()\n| B -> ()"
+ |]
+ [|
+ "a union with fields"
+ "type Shape = Circle of int | Square of int * int | Empty\nlet value = Empty"
+ "value"
+ "| Circle _ -> ()\n| Square _ -> ()\n| Empty -> ()"
+ |]
+ [|
+ "a union case that needs backticks"
+ "type U = | ``A B`` | C\nlet value = C"
+ "value"
+ "| ``A B`` -> ()\n| C -> ()"
+ |]
+ [|
+ "a RequireQualifiedAccess union in scope"
+ "[]\ntype U = A | B\nlet value = U.B"
+ "value"
+ "| U.A -> ()\n| U.B -> ()"
+ |]
+ [|
+ "a RequireQualifiedAccess union in a module that is not open"
+ "module Outer =\n []\n type U = A | B\n\nlet value = Outer.U.B"
+ "value"
+ "| Outer.U.A -> ()\n| Outer.U.B -> ()"
+ |]
+ [|
+ "a union in a module that is not open"
+ "module Outer =\n type U = A | B\n\nlet value = Outer.U.B"
+ "value"
+ "| Outer.U.A -> ()\n| Outer.U.B -> ()"
+ |]
+ [|
+ "a union in a module that is open"
+ "module Outer =\n type U = A | B\n\nopen Outer\nlet value = U.B"
+ "value"
+ "| A -> ()\n| B -> ()"
+ |]
+ [|
+ "an enum in a module that is not open"
+ "module Outer =\n type E = | X = 1 | Y = 2\n\nlet value = Outer.E.X"
+ "value"
+ "| Outer.E.X -> ()\n| Outer.E.Y -> ()\n| _ -> ()"
+ |]
+ [|
+ "a call, whose result is what is matched"
+ "type Input = X | Y\ntype Output = A | B\nlet make (_: Input) = B\nlet value = Y"
+ "make value"
+ "| A -> ()\n| B -> ()"
+ |]
+ [|
+ "a function value gives nothing"
+ "type U = A | B\nlet make (_: int) = B"
+ "make"
+ ""
+ |]
+ [| "a type that is neither a union nor an enum gives nothing"; ""; "1"; "" |]
+ |]
+
+[]
+let ``ClassName names the type the snippet lands in, without its enclosing modules, and nothing outside a type``
+ (_name: string, source: string, expected: string)
+ =
+ Assert.Equal(expected, containingTypeName source)
+
+[]
+let ``GenerateMatchCases spells each case the way it resolves at the match, and nothing for other types``
+ (_name: string, context: string, expression: string, expected: string)
+ =
+ Assert.Equal(expected, matchRules context expression)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs
new file mode 100644
index 00000000000..2763680997a
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module FSharp.Editor.Tests.SnippetIndentationTests
+
+open Xunit
+
+open Microsoft.VisualStudio.FSharp.Editor.SnippetIndentation
+
+/// Each line as its kind, the indentation the engine left it at, and the indentation it should end up at.
+let private scenarios =
+ [
+ "Surround With for over two lines nests both under the loop",
+ AroundSelection(12, 4),
+ [ Template, 0, 12; SelectedFirst, 16, 16; SelectedRest, 12, 16 ]
+
+ "Surround With async keeps the wrapper at the code's column",
+ AroundSelection(20, 4),
+ [ Template, 0, 20; SelectedFirst, 24, 24; Template, 0, 20 ]
+
+ "Surround With a directive pair pins it to column zero and does not nest",
+ AroundSelection(20, 0),
+ [ RootLevelDirective, 0, 0; SelectedFirst, 20, 20; RootLevelDirective, 0, 0 ]
+
+ "Insert Snippet leaves the opening line where the caret put it", AtCaret 8, [ Template, 8, 8; Template, 4, 12; Template, 0, 8 ]
+
+ "Insert Snippet after a tab nests the body by the tab's width",
+ AtCaret(advanceColumn 4 0 '\t'),
+ [ Template, 4, 4; Template, 4, 8; Template, 0, 4 ]
+
+ "Insert Snippet still pins a directive to column zero",
+ AtCaret 8,
+ [ RootLevelDirective, 8, 0; Template, 4, 12; RootLevelDirective, 0, 0 ]
+
+ "A blank line is left alone", AroundSelection(20, 4), [ Template, 0, 20; Blank, 0, 0; Template, 0, 20 ]
+
+ "A selection keeps its own internal shape", AroundSelection(12, 4), [ Template, 0, 12; SelectedFirst, 16, 16; SelectedRest, 16, 20 ]
+
+ "A line inside a string carried over from the selection is left alone",
+ AroundSelection(20, 4),
+ [ Template, 0, 20; SelectedFirst, 0, 0; InsideString, 0, 0 ]
+ ]
+
+let scenarioNames: obj[][] = [| for name, _, _ in scenarios -> [| name |] |]
+
+let private kindsOf selectedLines lines =
+ classify 4 selectedLines lines |> List.map _.Kind
+
+[]
+let ``Each line moves to where its kind and the placement put it`` (name: string) =
+ let _, placement, lines =
+ scenarios |> List.find (fun (scenario, _, _) -> scenario = name)
+
+ let engine =
+ lines |> List.map (fun (kind, indent, _) -> { Kind = kind; Indent = indent })
+
+ let expected = lines |> List.map (fun (_, _, indent) -> indent)
+
+ let actual =
+ deltas placement engine
+ |> List.map2 (fun (line: Line) delta -> line.Indent + delta) engine
+
+ Assert.Equal(expected, actual)
+
+[]
+[]
+[]
+[]
+[]
+let ``A tab runs on to the next tab stop and any other character takes one column``
+ (tabSize: int, column: int, character: char, expected: int)
+ =
+ Assert.Equal(expected, advanceColumn tabSize column character)
+
+[]
+let ``Indentation is measured in visual columns`` () =
+ Assert.Equal([ 6 ], classify 4 ValueNone [ "\t x" ] |> List.map _.Indent)
+
+[]
+let ``A directive is recognized wherever the engine left it`` () =
+ let lines, kinds =
+ [
+ "#if DEBUG", RootLevelDirective
+ " code", Template
+ " #elif TRACE", RootLevelDirective
+ " #else", RootLevelDirective
+ " code", Template
+ "#endif", RootLevelDirective
+ "#nowarn 0040", RootLevelDirective
+ " #warnon 0040", RootLevelDirective
+ ]
+ |> List.unzip
+
+ Assert.Equal(kinds, kindsOf ValueNone lines)
+
+[]
+[]
+[ ()">]
+[]
+let ``Code is not mistaken for a directive`` (line: string) =
+ Assert.Equal([ Template ], kindsOf ValueNone [ line ])
+
+[]
+let ``A blank line stays blank inside a branch the lexer skips`` () =
+ Assert.Equal([ RootLevelDirective; Blank; RootLevelDirective ], kindsOf ValueNone [ "#if A"; " "; "#endif" ])
+
+[]
+[]
+[]
+[]
+[ ignore")>]
+[]
+[]
+let ``A selected line that continues a string is left alone, even inside a directive wrapper`` (opening: string, continuation: string) =
+ Assert.Equal(
+ [ RootLevelDirective; SelectedFirst; InsideString; RootLevelDirective ],
+ kindsOf (ValueSome(1, 2)) [ "#if DEBUG"; opening; continuation; "#endif" ]
+ )
+
+[]
+[ printfn \"%s\"")>]
+[]
+let ``A selected line that only starts with a string, or sits in an interpolation hole, is code`` (first: string, rest: string) =
+ Assert.Equal([ SelectedFirst; SelectedRest ], kindsOf (ValueSome(0, 1)) [ first; rest ])
+
+[]
+[]
+[]
+let ``A directive the selection leaves unbalanced does not move the wrapper's own`` (selected: string) =
+ let kinds =
+ kindsOf (ValueSome(1, 2)) [ "#if DEBUG"; " let b = 3"; selected; "#endif" ]
+
+ Assert.Equal(RootLevelDirective, List.last kinds)