From cfe191a186e3009a0d9433cc4ee701baf12ff6b2 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:48 +0200 Subject: [PATCH 1/3] Ship a catalog of F# code snippets Insert Snippet and Surround With have had nothing to offer in an F# file: the Code Snippets Manager has no F# entry and this repository contains no `.snippet` file at all. Adds 40 snippets covering the part of the C# set that has an F# analogue - declarations, members, control flow, computation expressions - together with the registration and packaging that lets Visual Studio find them. `Languages\CodeExpansions\FSharp` is written into the pkgdef rather than produced by `ProvideLanguageCodeExpansionAttribute`, which does not expose the `Package` value that `DisplayName` resolves against; C#, VB, XAML, XML and TypeScript all register by hand for the same reason. Only 1033 is registered, and outright rather than as `%LCID%`: registering both would enumerate every snippet twice on an English VS. The shipped directory is `Snippets\1033\FSharp`, not `Visual F#`, because a '#' in a VSIX part URI reads as a URI fragment and the packaging step refuses it. `SnippetsIndex.xml` supplies the folder name the Code Snippets Manager shows. Bodies are authored at column 0 with 4-space relative indentation - absolute indentation is applied at insertion time - and every snippet carries an explicit `$end$`, which is what lets the expansion client avoid reading the snippet XML back out of the live session. Co-Authored-By: Claude Opus 5 (1M context) --- .../Vsix/RegisterFsharpPackage.pkgdef | 19 +++++++++ .../VisualFSharp.Core.targets | 14 +++++++ .../snippets/1033/FSharp/abstract.snippet | 35 ++++++++++++++++ .../snippets/1033/FSharp/async.snippet | 20 +++++++++ .../snippets/1033/FSharp/attribute.snippet | 26 ++++++++++++ .../snippets/1033/FSharp/class.snippet | 30 +++++++++++++ .../snippets/1033/FSharp/ctor.snippet | 36 ++++++++++++++++ .../snippets/1033/FSharp/dispose.snippet | 19 +++++++++ .../snippets/1033/FSharp/du.snippet | 42 +++++++++++++++++++ .../snippets/1033/FSharp/enum.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/equals.snippet | 30 +++++++++++++ .../snippets/1033/FSharp/exn.snippet | 30 +++++++++++++ .../snippets/1033/FSharp/ext.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/for.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/forr.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/if.snippet | 26 ++++++++++++ .../snippets/1033/FSharp/iface.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/ife.snippet | 28 +++++++++++++ .../snippets/1033/FSharp/interface.snippet | 36 ++++++++++++++++ .../snippets/1033/FSharp/lock.snippet | 26 ++++++++++++ .../snippets/1033/FSharp/main.snippet | 20 +++++++++ .../snippets/1033/FSharp/match.snippet | 32 ++++++++++++++ .../snippets/1033/FSharp/matcho.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/matchr.snippet | 36 ++++++++++++++++ .../snippets/1033/FSharp/matcht.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/matchvo.snippet | 31 ++++++++++++++ .../snippets/1033/FSharp/member.snippet | 25 +++++++++++ .../snippets/1033/FSharp/module.snippet | 26 ++++++++++++ .../snippets/1033/FSharp/nowarn.snippet | 27 ++++++++++++ .../snippets/1033/FSharp/override.snippet | 25 +++++++++++ .../snippets/1033/FSharp/pfn.snippet | 25 +++++++++++ .../snippets/1033/FSharp/pp_if.snippet | 27 ++++++++++++ .../snippets/1033/FSharp/prop.snippet | 30 +++++++++++++ .../snippets/1033/FSharp/propfull.snippet | 39 +++++++++++++++++ .../snippets/1033/FSharp/record.snippet | 36 ++++++++++++++++ .../snippets/1033/FSharp/seq.snippet | 20 +++++++++ .../snippets/1033/FSharp/struct.snippet | 37 ++++++++++++++++ .../snippets/1033/FSharp/task.snippet | 20 +++++++++ .../snippets/1033/FSharp/try.snippet | 21 ++++++++++ .../snippets/1033/FSharp/tryf.snippet | 21 ++++++++++ .../snippets/1033/FSharp/use.snippet | 30 +++++++++++++ .../snippets/1033/FSharp/while.snippet | 26 ++++++++++++ .../snippets/1033/SnippetsIndex.xml | 12 ++++++ 43 files changed, 1200 insertions(+) create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/abstract.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/async.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/attribute.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/class.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ctor.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/dispose.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/du.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/enum.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/equals.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/exn.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ext.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/for.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/forr.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/if.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/iface.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/ife.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/interface.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/main.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/match.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcho.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchr.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matcht.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/matchvo.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/member.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/module.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/nowarn.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/override.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pfn.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/pp_if.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/prop.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/propfull.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/record.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/seq.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/struct.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/task.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/try.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/tryf.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/use.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/while.snippet create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/SnippetsIndex.xml 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# + + + From 8181469a2048a2c50f6f5508ac093252e6e1a0bb Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:55 +0200 Subject: [PATCH 2/3] Expand and surround with code snippets in F# files 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. Nothing here reuses Roslyn: its snippet stack is `internal` under `LanguageServices.Implementation.Snippets` with no ExternalAccess surface, so F# writes its own `IVsExpansionClient` the way it already writes its own brace completion. The commands come in through one MEF `ICommandHandler<_>` part, ordered after the completion handler so that Tab still commits an open completion list first. Indentation is the F#-specific part. The expansion engine inserts snippet text verbatim, and C# gets away with that because Roslyn's formatter reflows the result afterwards; F# has no formatter, so `FormatSpan` computes the columns. That arithmetic lives in `SnippetIndentation`, free of editor types so that it can be tested on its own - the policy is where the mistakes live, not the buffer edit that applies it. A directive wrapper is its own line kind: `#if`/`#else`/`#endif` and the scoped `#nowarn`/`#warnon` pair read at the left margin whatever they wrap, so the code they cover keeps the column it had. Two things worth knowing for anyone reading `IVsExpansionClient` next to Roslyn's: `tsInsertPos` is the range `InsertNamedExpansion` replaces, so handing it the selection deletes the code a SurroundsWith snippet was meant to wrap; and `GetFieldSpan "selected"` does not answer for that special literal, so the substituted range is derived from the template's own `$selected$` line plus the line count the command handler took before the insertion. `ClassName()` and `GenerateMatchCases()` back the `ctor`, `equals` and `match` snippets. Both are synchronous COM callbacks, so they block; `ClassName()` blocks on a parse and `GenerateMatchCases()` on the stale-tolerant check-results path, falling back to a visible `| _ -> ()` rather than waiting unbounded. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/Common/Extensions.fs | 9 + vsintegration/src/FSharp.Editor/Common/Vs.fs | 11 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 4 + .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../LanguageService/FSharpEditorFactory.fs | 3 + .../Snippets/SnippetCommandHandler.fs | 187 +++++++++ .../Snippets/SnippetExpansionClient.fs | 371 ++++++++++++++++++ .../Snippets/SnippetFunctions.fs | 271 +++++++++++++ .../Snippets/SnippetIndentation.fs | 65 +++ .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 + .../xlf/FSharp.Editor.zh-Hans.xlf | 10 + .../xlf/FSharp.Editor.zh-Hant.xlf | 10 + .../FSharp.Editor.Tests.fsproj | 11 + .../Snippets/SnippetCatalogTests.fs | 240 +++++++++++ .../Snippets/SnippetIndentationTests.fs | 101 +++++ 26 files changed, 1410 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs create mode 100644 vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs create mode 100644 vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs create mode 100644 vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..2caa1c4c059 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. ### Fixed 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) From ba5de400235408876fe9837e2a4b711546ca580c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 11:50:23 +0200 Subject: [PATCH 3/3] Link the snippets release note to its issue and pull request Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2caa1c4c059..9b0ea71011c 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,7 +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. +* 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