diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index ba03f663967..9b0ea71011c 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -2,6 +2,7 @@
* Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))
+* 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))
### 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/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..b666a4bfc22
--- /dev/null
+++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet
@@ -0,0 +1,26 @@
+
+
+
+
+ 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/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs
index f9695e68ecf..19a9a94762f 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))
+ /// The line ending the file itself uses at `position`, so that inserted text does not mix its
+ /// own convention into the document. Falls back to the host's for a file with a single line.
+ member this.LineBreakAt(position: int) =
+ let line = this.Lines.GetLineFromPosition position
+
+ match this.ToString(TextSpan(line.End, line.EndIncludingLineBreak - line.End)) with
+ | "" -> Environment.NewLine
+ | lineBreak -> lineBreak
+
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..2c84fef73c8 100644
--- a/vsintegration/src/FSharp.Editor/Common/Vs.fs
+++ b/vsintegration/src/FSharp.Editor/Common/Vs.fs
@@ -97,6 +97,17 @@ module internal ServiceProviderExtensions =
member sp.TextManager = sp.GetService()
+ member sp.ExpansionManager =
+ match sp.GetService() with
+ | null -> null
+ | textManager ->
+ let mutable expansionManager = Unchecked.defaultof
+
+ if Com.Succeeded(textManager.GetExpansionManager(&expansionManager)) then
+ expansionManager
+ else
+ 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..1dc814a7f86
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs
@@ -0,0 +1,187 @@
+// 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.ComponentModel.Composition
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Text
+
+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.Utilities
+
+open FSharp.Compiler.EditorServices
+
+open CancellableTasks
+
+[]
+module internal SnippetCommandHelpers =
+
+ []
+ let private userOpName = "FSharpSnippetCommandHandler"
+
+ /// The snippet shortcut the caret is sitting at the end of. 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
+ }
+
+ /// Drops a trailing line break from the selection, changing what is selected and nothing else.
+ ///
+ /// 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.
+ ///
+ /// The column is the narrowest indentation in the block, not the first line's: the wrapper belongs
+ /// at the block's own left edge even when the block starts with a deeper line.
+ let selectionShape (textView: ITextView) =
+ let span = textView.Selection.StreamSelectionSpan.SnapshotSpan
+ let snapshot = span.Snapshot
+ let firstLine = snapshot.GetLineFromPosition span.Start.Position
+ let lastLine = snapshot.GetLineFromPosition span.End.Position
+
+ let column =
+ seq { firstLine.LineNumber .. lastLine.LineNumber }
+ |> Seq.fold
+ (fun narrowest lineNumber ->
+ let line = snapshot.GetLineFromLineNumber lineNumber
+
+ match leadingWhitespaceOf line with
+ | indent when indent = line.Length -> narrowest
+ | indent -> min narrowest indent)
+ Int32.MaxValue
+
+ let column = if column = Int32.MaxValue then 0 else column
+
+ column, lastLine.LineNumber - firstLine.LineNumber + 1
+
+/// Insert Snippet (Ctrl+K,Ctrl+X), Surround With (Ctrl+K,Ctrl+S), Tab expansion of a snippet
+/// shortcut, and the keys that drive a live expansion session.
+///
+/// Ordered after the completion handler so that Tab still commits an open completion list first,
+/// which is how the C# handler is ordered too.
+[)>]
+[]
+[]
+[]
+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
+
+ /// Only a live session gets to see Tab, Shift+Tab, Enter and Escape.
+ let tryGetSessionClient (textView: ITextView) (subjectBuffer: ITextBuffer) =
+ tryGetClient textView subjectBuffer |> ValueOption.filter _.IsInSession
+
+ let tryExpandShortcut (args: TabKeyCommandArgs) (client: FSharpSnippetExpansionClient) =
+ match args.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges() with
+ | null -> false
+ | document when not document.Project.IsFSharp -> false
+ | document ->
+ let caret = args.TextView.Caret.Position.BufferPosition.Position
+
+ match runSynchronously parseTimeout (tryGetShortcutAt document caret) with
+ | ValueNone -> false
+ | ValueSome(shortcut, range) ->
+ let shortcutSpan =
+ VsTextSpan(
+ iStartLine = range.StartLine - 1,
+ iStartIndex = range.StartColumn,
+ iEndLine = range.EndLine - 1,
+ iEndIndex = range.EndColumn
+ )
+
+ client.TryInsertExpansionForShortcut(shortcut, shortcutSpan)
+
+ // `ICommandHandler<_>` inherits `INamed`, so the name is given once for all six of them.
+ 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$`, and editing first was what yanked the code to column 0.
+ 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..d7fa7ba9a78
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs
@@ -0,0 +1,371 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+// This implementation does not rely on Roslyn internals: everything Roslyn has for snippets lives in
+// `Microsoft.VisualStudio.LanguageServices.Implementation.Snippets`, which is internal and has no
+// ExternalAccess surface. Roslyn's `SnippetExpansionClient` is the design reference, not a base class.
+
+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.TextManager.Interop
+
+open MSXML
+
+[]
+module internal SnippetExpansionHelpers =
+
+ /// Measured on the snapshot itself: the caller only needs the width, and `GetText()` would copy the
+ /// whole line to get it.
+ 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
+
+ /// Indentation spelled the way the document is configured to spell it, rather than the way this
+ /// file happens to. F# registers `DefaultToInsertSpaces`, but the setting is the user's.
+ let indentTextOf (options: IEditorOptions) width =
+ if options.GetOptionValue DefaultOptions.ConvertTabsToSpacesOptionId then
+ String(' ', width)
+ else
+ let tabSize = options.GetOptionValue DefaultOptions.TabSizeOptionId
+ String('\t', width / tabSize) + String(' ', width % tabSize)
+
+ /// Whether the line starts a directive wrapper, asking the snapshot for the one character that
+ /// settles it before copying the line out to compare prefixes.
+ let startsRootLevelDirective (line: ITextSnapshotLine) indent =
+ line.Snapshot[line.Start.Position + indent] = '#'
+ && SnippetIndentation.isRootLevelDirective (line.GetText())
+
+ /// Where `$selected$` sits in a snippet's ``: which of its lines holds the field, and the
+ /// column the template indents it to. That is the one nesting level a wrapper contributes, and 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
+
+ /// Splits `GenerateMatchCases($expression$)` into its name and its `$field$` arguments.
+ 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
+ /// Which line of the template holds `$selected$`, and the column it indents it to.
+ FieldLine: int
+ FieldIndent: int
+ }
+
+/// Drives one snippet expansion in one text view. VS owns the session; this is the callback surface
+/// it drives, plus the handful of operations the command handler needs.
+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
+
+ /// Set when Surround With opens the picker, before the template is known.
+ let mutable pendingSurround = 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
+
+ member private _.TryGetExpansion() =
+ match editorAdapters.GetBufferAdapter subjectBuffer with
+ | :? IVsExpansion as expansion -> ValueSome expansion
+ | _ -> ValueNone
+
+ /// Where the expansion goes: the caret, as an empty span.
+ ///
+ /// It must not be the selection. `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$`, which
+ /// is why the legacy `ExpansionProvider.OnItemChosen` passes `GetCaretPos` and nothing else.
+ 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) =
+ match this.TryGetExpansion() with
+ | ValueNone -> false
+ | ValueSome expansion ->
+ // The picker has named the template, so the field's place in it can be read now.
+ surround <-
+ match pendingSurround, tryReadSelectedFieldLayout path with
+ | ValueSome(column, lineCount), ValueSome(fieldLine, fieldIndent) ->
+ ValueSome
+ {
+ Column = column
+ LineCount = lineCount
+ FieldLine = fieldLine
+ FieldIndent = fieldIndent
+ }
+ | _ -> ValueNone
+
+ indentPending <- true
+ let mutable session = Unchecked.defaultof
+
+ let hr =
+ expansion.InsertNamedExpansion(title, path, insertionSpan, this, languageGuid, 0, &session)
+
+ not (ErrorHandler.Failed hr)
+
+ /// Expands the snippet registered under `shortcut`, replacing `shortcutSpan`.
+ member this.TryInsertExpansionForShortcut(shortcut: string, shortcutSpan: VsTextSpan) =
+ match ServiceProvider.GlobalProvider.ExpansionManager, editorAdapters.GetViewAdapter textView with
+ | null, _
+ | _, null -> false
+ | expansionManager, viewAdapter ->
+ let spans = [| shortcutSpan |]
+ let mutable path = null
+ let mutable title = null
+
+ let hr =
+ expansionManager.GetExpansionByShortcut(this, languageGuid, shortcut, viewAdapter, spans, 0, &path, &title)
+
+ if ErrorHandler.Failed hr then
+ false
+ else
+ match path with
+ | null -> false
+ | path -> this.InsertNamedExpansion(title, path, spans[0])
+
+ /// Shows a snippet picker. It is not modal: the chosen item comes back later through `OnItemChosen`.
+ member private this.InvokeInsertionUI(types: string[], prompt) =
+ match ServiceProvider.GlobalProvider.ExpansionManager, editorAdapters.GetViewAdapter textView with
+ | null, _
+ | _, null -> false
+ | expansionManager, viewAdapter ->
+ let hr =
+ expansionManager.InvokeInsertionUI(viewAdapter, this, languageGuid, types, types.Length, 1, null, 0, 0, prompt, null)
+
+ not (ErrorHandler.Failed hr)
+
+ member this.TryInsertSnippet() =
+ pendingSurround <- ValueNone
+ surround <- ValueNone
+ this.InvokeInsertionUI([| "Expansion"; "SurroundsWith" |], SR.InsertSnippet())
+
+ /// `column` is where the selected code sits and `lineCount` how many lines it covers; neither
+ /// survives the insertion, which replaces the selection.
+ member this.TrySurroundWith(column: int, lineCount: int) =
+ pendingSurround <- ValueSome(column, lineCount)
+ this.InvokeInsertionUI([| "SurroundsWith" |], SR.SurroundWith())
+
+ 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
+
+ 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
+ pendingSurround <- ValueNone
+ surround <- ValueNone
+ VSConstants.S_OK
+
+ member this.OnItemChosen(pszTitle, pszPath) =
+ match this.TryGetCaretSpan() with
+ | ValueSome span -> this.InsertNamedExpansion(pszTitle, pszPath, span) |> 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
+
+ /// The expansion engine inserts the snippet verbatim: its first line lands at the insertion
+ /// column, every later line at the column the template spells. F# has no formatter to reflow
+ /// that, so the indentation is this method's job, and each kind of line wants a different one:
+ ///
+ /// - a root-level directive (`#if`, `#endif`) belongs at column 0 whatever it wraps;
+ /// - text the engine substituted into `$selected$` already carries the indentation it had in
+ /// the buffer, and needs only the nesting the template adds around the field;
+ /// - every other line is the snippet's own, and takes the column of the code it wraps -
+ /// the caret's for Insert Snippet, the selection's for Surround With.
+ member _.FormatSpan(_buffer, ts: VsTextSpan[]) =
+ if indentPending && ts.Length > 0 then
+ indentPending <- false
+ let span = ts[0]
+ let snapshot = subjectBuffer.CurrentSnapshot
+
+ // `GetFieldSpan "selected"` does not answer for that special literal, so the range is
+ // derived instead: the template says which of its lines holds the field and at what
+ // column, and the command handler counted the lines the selection covered.
+ let selectedLines =
+ match surround with
+ | ValueSome s -> ValueSome(span.iStartLine + s.FieldLine, span.iStartLine + s.FieldLine + s.LineCount - 1)
+ | ValueNone -> ValueNone
+
+ let placement =
+ match surround with
+ | ValueSome s -> SnippetIndentation.AroundSelection(s.Column, s.FieldIndent)
+ | ValueNone -> SnippetIndentation.AtCaret span.iStartIndex
+
+ let lastLine = min span.iEndLine (snapshot.LineCount - 1)
+
+ let lines =
+ [
+ for lineNumber in span.iStartLine .. lastLine ->
+ let line = snapshot.GetLineFromLineNumber lineNumber
+ let indent = leadingWhitespaceOf line
+
+ let kind =
+ if indent = line.Length then
+ SnippetIndentation.Blank
+ elif startsRootLevelDirective line indent then
+ SnippetIndentation.RootLevelDirective
+ else
+ match selectedLines with
+ | ValueSome(first, _) when lineNumber = first -> SnippetIndentation.SelectedFirst
+ | ValueSome(first, last) when lineNumber > first && lineNumber <= last ->
+ SnippetIndentation.SelectedRest
+ | _ -> SnippetIndentation.Template
+
+ {
+ SnippetIndentation.Kind = kind
+ SnippetIndentation.Indent = indent
+ }
+ ]
+
+ use edit = subjectBuffer.CreateEdit()
+
+ SnippetIndentation.deltas placement lines
+ |> List.iteri (fun offset delta ->
+ let line = snapshot.GetLineFromLineNumber(span.iStartLine + offset)
+
+ if delta > 0 then
+ edit.Insert(line.Start.Position, indentTextOf textView.Options delta) |> ignore
+ elif delta < 0 then
+ edit.Delete(line.Start.Position, -delta) |> ignore)
+
+ edit.Apply() |> ignore
+
+ 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..5b21b3af83e
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs
@@ -0,0 +1,271 @@
+// 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.EditorServices
+open FSharp.Compiler.Symbols
+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 positionOf (snapshot: ITextSnapshot) line index =
+ snapshot.GetLineFromLineNumber(line).Start.Position + index
+
+ // The engine can build a snippet function before it opens the session, so both of these have to
+ // tolerate not having one yet.
+ let tryGetSnippetSpan (session: IVsExpansionSession) =
+ match session with
+ | null -> ValueNone
+ | session ->
+ let spans = Array.zeroCreate 1
+
+ if Com.Succeeded(session.GetSnippetSpan spans) then
+ ValueSome spans[0]
+ else
+ ValueNone
+
+ let tryGetFieldSpan (session: IVsExpansionSession) field =
+ match session with
+ | null -> ValueNone
+ | session ->
+ let spans = Array.zeroCreate 1
+
+ if Com.Succeeded(session.GetFieldSpan(field, spans)) then
+ ValueSome spans[0]
+ else
+ ValueNone
+
+ /// The expansion engine calls `IVsExpansionFunction` synchronously on the UI thread while the
+ /// session is live, so there is nowhere to await. `JoinableTaskFactory.Run` is the same blocking
+ /// bridge `FSharpGraphProvider` uses for the Code Map action handler; the timeout keeps a cold
+ /// project from turning that block into a hang, at the cost of falling back to the literal's
+ /// declared default.
+ 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. A snippet field is not worth taking the IDE down for.
+ | e ->
+ FSharpOutputPane.logException e
+ ValueNone
+
+ let tryGetDocument (subjectBuffer: ITextBuffer) =
+ match subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges() with
+ | null -> ValueNone
+ | document when document.Project.IsFSharp -> ValueSome document
+ | _ -> ValueNone
+
+ /// The name of the innermost type declaration whose body contains `position`.
+ let tryGetContainingTypeName (document: Document) position =
+ cancellableTask {
+ let! parseResults = document.GetFSharpParseResultsAsync userOpName
+ let! ct = CancellableTask.getCancellationToken ()
+ let! sourceText = document.GetTextAsync ct
+
+ let line = sourceText.Lines.GetLineFromPosition position
+ let caret = Position.mkPos (line.LineNumber + 1) (position - line.Start)
+
+ let innermost =
+ (Navigation.getNavigation parseResults.ParseTree).Declarations
+ |> Array.fold
+ (fun innermost topLevel ->
+ let declaration = topLevel.Declaration
+
+ if
+ declaration.Kind <> NavigationItemKind.Type
+ || not (Range.rangeContainsPos declaration.BodyRange caret)
+ then
+ innermost
+ else
+ match innermost with
+ | ValueSome(previous: NavigationItem) when previous.BodyRange.StartLine >= declaration.BodyRange.StartLine ->
+ innermost
+ | _ -> ValueSome declaration)
+ ValueNone
+
+ return innermost |> ValueOption.map _.LogicalName
+ }
+
+ /// The type an expression evaluates to: for a call, what is left once its arguments are applied.
+ let rec private resultTypeOf (fsharpType: FSharpType) =
+ if fsharpType.IsFunctionType then
+ resultTypeOf fsharpType.GenericArguments[1]
+ else
+ fsharpType.StripAbbreviations()
+
+ /// Lazy on purpose: `String.Join` is the one consumer and it materializes the text directly,
+ /// so no intermediate collection of rules is ever built.
+ let private matchRulesFor (entity: FSharpEntity) =
+ if entity.IsFSharpUnion then
+ entity.UnionCases
+ |> Seq.map (fun case ->
+ if case.HasFields then
+ $"| %s{case.Name} _ -> ()"
+ else
+ $"| %s{case.Name} -> ()")
+ elif entity.IsEnum then
+ seq {
+ for field in entity.FSharpFields do
+ if field.LiteralValue.IsSome then
+ $"| %s{entity.DisplayName}.%s{field.Name} -> ()"
+
+ // An enum value need not be one of the declared literals, so the wildcard is not optional.
+ "| _ -> ()"
+ }
+ else
+ Seq.empty
+
+ let private matchRulesForUse (symbolUse: FSharpSymbolUse) =
+ match symbolUse.Symbol with
+ | :? FSharpMemberOrFunctionOrValue as value ->
+ let resultType = resultTypeOf value.FullType
+
+ if resultType.HasTypeDefinition then
+ matchRulesFor resultType.TypeDefinition
+ else
+ Seq.empty
+ | _ -> Seq.empty
+
+ /// The match rules covering the union or enum at `position`, or ValueNone for anything else.
+ let tryGetMatchRules (document: Document) position =
+ cancellableTask {
+ let! lexerSymbol = document.TryFindFSharpLexerSymbolAsync(position, SymbolLookupKind.Greedy, false, false, userOpName)
+ let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName
+ let! ct = CancellableTask.getCancellationToken ()
+ let! sourceText = document.GetTextAsync ct
+
+ let line = sourceText.Lines.GetLineFromPosition position
+
+ let rules =
+ lexerSymbol
+ |> ValueOption.ofOption
+ |> ValueOption.bind (fun symbol ->
+ checkResults.GetSymbolUseAtLocation(
+ line.LineNumber + 1,
+ symbol.Ident.idRange.EndColumn,
+ line.ToString(),
+ symbol.FullIsland
+ )
+ |> ValueOption.ofOption)
+ |> ValueOption.map matchRulesForUse
+ |> ValueOption.defaultValue Seq.empty
+
+ return
+ match String.Join(sourceText.LineBreakAt position, rules) with
+ | "" -> ValueNone
+ | rules -> ValueSome rules
+ }
+
+/// One `` declared by a snippet literal. `arguments` are the raw `$field$` references the
+/// snippet passed, which is what tells us whether a field edit invalidates our value.
+[]
+type internal FSharpSnippetFunction(getSession: unit -> IVsExpansionSession, subjectBuffer: ITextBuffer, arguments: string[]) =
+
+ /// The engine can build a function before it opens the session, so this is read per call.
+ member _.Session = getSession ()
+ member _.SubjectBuffer = subjectBuffer
+
+ 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
+
+/// `ClassName()` — the F# counterpart of the C# snippet function of the same name.
+type internal SnippetFunctionClassName(getSession, subjectBuffer: ITextBuffer, arguments) =
+ inherit FSharpSnippetFunction(getSession, subjectBuffer, arguments)
+
+ override this.TryGetValue() =
+ match tryGetDocument subjectBuffer, tryGetSnippetSpan this.Session with
+ | ValueSome document, ValueSome span ->
+ let position =
+ positionOf subjectBuffer.CurrentSnapshot span.iStartLine span.iStartIndex
+
+ // Parse results are cached per document version, so the timeout only bites on the first
+ // parse of a freshly opened file.
+ runSynchronously parseTimeout (tryGetContainingTypeName document position)
+ | _ -> ValueNone
+
+/// `GenerateMatchCases($field$)` — the F# counterpart of C#'s `GenerateSwitchCases`, covering
+/// discriminated unions as well as enums.
+type internal SnippetFunctionGenerateMatchCases(getSession, subjectBuffer: ITextBuffer, arguments: string[]) =
+ inherit FSharpSnippetFunction(getSession, subjectBuffer, arguments)
+
+ /// The single argument names the field holding the expression to match on, delimited as `$name$`.
+ 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 ->
+ let position = positionOf subjectBuffer.CurrentSnapshot span.iEndLine span.iEndIndex
+
+ // Resolving the user's expression needs a check of the text they just typed, so there is
+ // no cached answer to fall back on - only the literal's declared default.
+ runSynchronously document.Project.FSharpTimeUntilStaleCompletion (tryGetMatchRules document position)
+ | _ -> 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..9c96e624d5a
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System
+
+/// Where the lines of an inserted snippet belong, as arithmetic over columns.
+///
+/// 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.
+///
+/// This module is deliberately free of editor types so that it can be tested directly - the policy
+/// is where the mistakes live, not the buffer edit that applies it.
+module internal SnippetIndentation =
+
+ /// What an inserted line is, which is what decides how it moves.
+ 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
+ /// Whitespace only; left alone so the snippet does not leave trailing spaces behind.
+ | Blank
+
+ type Line = { Kind: LineKind; Indent: int }
+
+ /// How the snippet got there, which is what supplies the column to align to.
+ 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 private rootLevelDirectives =
+ [| "#if"; "#else"; "#endif"; "#nowarn"; "#warnon" |]
+
+ /// Whether a snippet line is a compiler directive rather than code. Those wrappers belong at the
+ /// left margin whatever they wrap, so the code they cover keeps the column it had. `#nowarn` and
+ /// `#warnon` are scoped, but they read as directives all the same.
+ let isRootLevelDirective (lineText: string) =
+ let text = lineText.TrimStart()
+
+ rootLevelDirectives
+ |> Array.exists (fun directive -> text.StartsWith(directive, StringComparison.Ordinal))
+
+ /// 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, _ -> 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..86f06113035 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -85,6 +85,17 @@
+
+
+
+
+
+
+
+ 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..df538701405
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs
@@ -0,0 +1,240 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Editor.Tests
+
+open System
+open System.IO
+open System.Text.RegularExpressions
+open System.Xml.Linq
+
+open Xunit
+
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.Diagnostics
+open FSharp.Compiler.Text
+
+/// Guards the shipped `.snippet` catalog: the files are content, so nothing else would notice a
+/// malformed one until it silently failed to show up in Visual Studio.
+module SnippetCatalog =
+
+ let private ns =
+ XNamespace.Get "http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet"
+
+ let directory = Path.Combine(AppContext.BaseDirectory, "Snippets", "1033", "FSharp")
+
+ let indexPath =
+ Path.Combine(AppContext.BaseDirectory, "Snippets", "1033", "SnippetsIndex.xml")
+
+ let files = Directory.GetFiles(directory, "*.snippet") |> Array.sort
+
+ type Snippet =
+ {
+ Name: string
+ Title: string
+ Shortcut: string
+ Types: string list
+ Literals: (string * string) list
+ Code: string
+ }
+
+ member this.IsSurroundsWith = this.Types |> List.contains "SurroundsWith"
+
+ let load path =
+ let document = XDocument.Load(path: string)
+ let snippet = document.Descendants(ns + "CodeSnippet") |> Seq.exactlyOne
+ let header = snippet.Element(ns + "Header")
+ let body = snippet.Element(ns + "Snippet")
+
+ {
+ Name = Path.GetFileNameWithoutExtension path
+ Title = header.Element(ns + "Title").Value
+ Shortcut = header.Element(ns + "Shortcut").Value
+ Types = [ for element in header.Descendants(ns + "SnippetType") -> element.Value ]
+ Literals =
+ [
+ for literal in body.Descendants(ns + "Literal") ->
+ literal.Element(ns + "ID").Value, literal.Element(ns + "Default").Value
+ ]
+ Code = body.Element(ns + "Code").Value
+ }
+
+ /// The snippet as the user first sees it: every literal at its default, the surrounded text
+ /// absent, and `()` parked where the caret ends up. `$end$` always occupies a whole expression
+ /// position, which is what makes that substitution meaningful.
+ let expand snippet =
+ let withDefaults =
+ snippet.Literals
+ |> List.fold (fun (code: string) (id, dflt) -> code.Replace($"$%s{id}$", dflt)) snippet.Code
+
+ withDefaults.Replace("$selected$", "").Replace("$end$", "do ()").Replace("$$", "$")
+
+ let private checker = FSharpChecker.Create()
+
+ let private indent (by: int) (text: string) =
+ let pad = String(' ', by)
+
+ text.Split '\n'
+ |> Seq.map (fun line ->
+ let line = line.TrimEnd '\r'
+ if line.Trim() = "" then line else pad + line)
+ |> String.concat "\n"
+
+ /// Where a snippet body can legally appear. 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{indent 4 code}\n")
+ "function body", (fun code -> $"module TestHost\n\nlet f () =\n%s{indent 4 code}\n")
+ ]
+
+ let private parseErrors source =
+ let options =
+ { FSharpParsingOptions.Default with
+ SourceFiles = [| "Test.fs" |]
+ }
+
+ checker.ParseFile("Test.fs", SourceText.ofString source, options)
+ |> Async.RunSynchronously
+ |> _.Diagnostics
+ |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error)
+
+ /// The host the expanded body parses in, if any.
+ let tryParseInSomeHost code =
+ hosts
+ |> List.tryPick (fun (name, host) ->
+ match parseErrors (host code) with
+ | [||] -> Some name
+ | _ -> None)
+
+ let firstParseError code =
+ hosts
+ |> Seq.map (fun (name, host) ->
+ let message =
+ parseErrors (host code)
+ |> Seq.truncate 1
+ |> Seq.map _.Message
+ |> String.concat ""
+
+ $"%s{name}: %s{message}")
+ |> String.concat "; "
+
+type SnippetCatalogTests() =
+
+ static member snippetNames: obj[][] =
+ [|
+ for path in SnippetCatalog.files -> [| Path.GetFileNameWithoutExtension path |]
+ |]
+
+ static member private load name =
+ SnippetCatalog.load (Path.Combine(SnippetCatalog.directory, $"%s{name}.snippet"))
+
+ []
+ member _.``The catalog ships the snippets the registration promises``() =
+ Assert.Equal(40, SnippetCatalog.files.Length)
+ Assert.True(File.Exists SnippetCatalog.indexPath, $"missing {SnippetCatalog.indexPath}")
+
+ []
+ member _.``Shortcuts and titles are unique``() =
+ let snippets = SnippetCatalog.files |> Array.map SnippetCatalog.load
+
+ let duplicatesBy key =
+ snippets |> Seq.countBy key |> Seq.filter (fun (_, count) -> count > 1)
+
+ Assert.Empty(duplicatesBy _.Shortcut)
+ Assert.Empty(duplicatesBy _.Title)
+
+ []
+ []
+ member _.``Snippet declares an Expansion type and a title matching its shortcut``(name: string) =
+ let snippet = SnippetCatalogTests.load name
+
+ Assert.Contains("Expansion", snippet.Types)
+ Assert.Equal(snippet.Shortcut, snippet.Title)
+
+ // `pp_if` follows C#, which cannot name a file `#if`.
+ if name <> "pp_if" then
+ Assert.Equal(name, snippet.Shortcut)
+
+ []
+ []
+ member _.``Snippet literals are all declared and all used``(name: string) =
+ let snippet = SnippetCatalogTests.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
+
+ let declared = snippet.Literals |> List.map fst |> Set.ofList
+
+ Assert.Equal>(declared, referenced)
+
+ []
+ []
+ member _.``Snippet marks the caret position and its surround field``(name: string) =
+ let snippet = SnippetCatalogTests.load name
+
+ // An explicit `$end$` is what lets the expansion client skip reading the snippet XML back
+ // out of the live session, which is the call that needs Roslyn's IVsExpansionSessionInternal
+ // workaround.
+ Assert.Contains("$end$", snippet.Code)
+
+ Assert.Equal(snippet.IsSurroundsWith, snippet.Code.IndexOf("$selected$", StringComparison.Ordinal) >= 0)
+
+ if snippet.IsSurroundsWith then
+ // The expansion engine indents the substituted text from the column the template put the
+ // field at, so anything preceding it on its line would offset the whole wrapped block.
+ let selectedLine =
+ snippet.Code.Split '\n'
+ |> Array.find (fun line -> line.IndexOf("$selected$", StringComparison.Ordinal) >= 0)
+
+ Assert.Equal("$selected$", selectedLine.TrimStart().Substring(0, "$selected$".Length))
+
+ []
+ []
+ member _.``The field layout the expansion client reads back matches the file``(name: string) =
+ // Surround With indents the wrapped code by whatever the template indents `$selected$` by, and
+ // the live session will not report that, so the client re-reads it from the `.snippet` itself.
+ // An unreadable layout silently degrades every wrapped line to the snippet's own column.
+ let snippet = SnippetCatalogTests.load name
+ let path = Path.Combine(SnippetCatalog.directory, $"%s{name}.snippet")
+
+ let layout =
+ Microsoft.VisualStudio.FSharp.Editor.SnippetExpansionHelpers.tryReadSelectedFieldLayout path
+
+ if snippet.IsSurroundsWith then
+ let lines = snippet.Code.Replace("\r\n", "\n").Split '\n'
+
+ let fieldLine =
+ lines
+ |> Array.findIndex (fun line -> line.IndexOf("$selected$", StringComparison.Ordinal) >= 0)
+
+ let fieldIndent = lines[fieldLine].Length - lines[fieldLine].TrimStart().Length
+
+ Assert.Equal(ValueSome(fieldLine, fieldIndent), layout)
+ else
+ Assert.Equal(ValueNone, layout)
+
+ []
+ []
+ member _.``Snippet body is authored at column zero with spaces``(name: string) =
+ let snippet = SnippetCatalogTests.load name
+
+ Assert.DoesNotContain("\t", snippet.Code)
+
+ // Absolute indentation comes from FormatSpan at insertion time, not from the file.
+ Assert.False(snippet.Code.StartsWith(" ", StringComparison.Ordinal), "body must start at column 0")
+
+ []
+ []
+ member _.``Snippet expands to F# that parses``(name: string) =
+ let snippet = SnippetCatalogTests.load name
+ let code = SnippetCatalog.expand snippet
+
+ match SnippetCatalog.tryParseInSomeHost code with
+ | Some _ -> ()
+ | None -> failwith $"%s{name} does not parse in any host: %s{SnippetCatalog.firstParseError code}\n---\n%s{code}"
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..d17787f4d5c
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Editor.Tests
+
+open Xunit
+
+open Microsoft.VisualStudio.FSharp.Editor.SnippetIndentation
+
+/// Every case here is a real insertion that came out wrong at some point, recorded as the columns the
+/// expansion engine left behind and the columns the result should have.
+module SnippetIndentationTests =
+
+ /// The indentation each line ends up at, which is what a reader can check against F# they know.
+ let private columnsAfter placement lines =
+ let moved = deltas placement lines
+
+ List.map2 (fun line delta -> line.Indent + delta) lines moved
+
+ let private template indent = { Kind = Template; Indent = indent }
+
+ let private directive indent =
+ {
+ Kind = RootLevelDirective
+ Indent = indent
+ }
+
+ let private selectedFirst indent =
+ {
+ Kind = SelectedFirst
+ Indent = indent
+ }
+
+ let private selectedRest indent =
+ { Kind = SelectedRest; Indent = indent }
+
+ []
+ let ``Surround With for over two lines nests both under the loop`` () =
+ // fields = [
+ // yield Define.Field … <- the two selected lines, at column 12
+ // yield Define.AsyncField …
+ // Template is `for $item$ in $collection$ do` / ` $selected$$end$`, so the engine leaves the
+ // first selected line at 4 + 12 and the second at its own 12.
+ let lines = [ template 0; selectedFirst 16; selectedRest 12 ]
+
+ Assert.Equal([ 12; 16; 16 ], columnsAfter (AroundSelection(12, 4)) lines)
+
+ []
+ let ``Surround With async keeps the wrapper at the code's column`` () =
+ // `async {` and `}` are the snippet's own lines and belong at the wrapped code's column, not at
+ // the column 0 the verbatim insertion left them at.
+ let lines = [ template 0; selectedFirst 24; template 0 ]
+
+ Assert.Equal([ 20; 24; 20 ], columnsAfter (AroundSelection(20, 4)) lines)
+
+ []
+ let ``Surround With a directive pair pins it to column zero and does not nest`` () =
+ // A directive wrapper - `#if`/`#endif`, or the scoped `#nowarn`/`#warnon` pair - wraps code
+ // without indenting it, so `$selected$` sits at template column 0 and the wrapped lines keep
+ // the columns they had.
+ let lines = [ directive 0; selectedFirst 20; directive 0 ]
+
+ Assert.Equal([ 0; 20; 0 ], columnsAfter (AroundSelection(20, 0)) lines)
+
+ []
+ []
+ []
+ []
+ []
+ []
+ let ``A directive is recognized wherever the engine left it`` (line: string) = Assert.True(isRootLevelDirective line)
+
+ []
+ []
+ [ ()">]
+ let ``Code is not mistaken for a directive`` (line: string) = Assert.False(isRootLevelDirective line)
+
+ []
+ let ``Insert Snippet leaves the opening line where the caret put it`` () =
+ // The caret positioned `async {`; the body and the closing brace follow its column.
+ let lines = [ template 8; template 4; template 0 ]
+
+ Assert.Equal([ 8; 12; 8 ], columnsAfter (AtCaret 8) lines)
+
+ []
+ let ``Insert Snippet still pins a directive to column zero`` () =
+ let lines = [ directive 8; template 4; directive 0 ]
+
+ Assert.Equal([ 0; 12; 0 ], columnsAfter (AtCaret 8) lines)
+
+ []
+ let ``A blank line is left alone`` () =
+ let lines = [ template 0; { Kind = Blank; Indent = 0 }; template 0 ]
+
+ Assert.Equal([ 20; 0; 20 ], columnsAfter (AroundSelection(20, 4)) lines)
+
+ []
+ let ``A selection keeps its own internal shape`` () =
+ // A deeper second line stays one level deeper than the first.
+ let lines = [ template 0; selectedFirst 16; selectedRest 16 ]
+
+ Assert.Equal([ 12; 16; 20 ], columnsAfter (AroundSelection(12, 4)) lines)