From a18d95ac91dffe8cd8b52fd20beef6b904ef40b5 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:48 +0200 Subject: [PATCH 1/6] 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 e36fdb41adee83ae82cf1bef3a410c0df9bc5855 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:55 +0200 Subject: [PATCH 2/6] 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 e6034dca8df..91a6538a76d 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 9c67e6c2ac41fb803c8cbca77cbb7544a61ab794 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 11:50:23 +0200 Subject: [PATCH 3/6] 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 91a6538a76d..47927c6d29f 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 From 373db75b85dfd9899b7a86227fe519ef96511a47 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 18:54:49 +0200 Subject: [PATCH 4/6] Fix the five review findings on the snippet catalog and indentation A RequireQualifiedAccess union rejects a bare case pattern (`A`, not `U.A`) - the generated pattern binds a fresh variable instead of testing the case, so it takes the first arm regardless of the actual value. matchRulesFor now qualifies with the entity's DisplayName when the union carries the attribute, the same way it already does for enums. ClassName() used the navigation item's LogicalName, which is qualified by every enclosing module ("Outer.C") - it does not resolve from a constructor sitting inside C's own scope. Strips to the name after the last '.'. GenerateMatchCases() resolved the lexer symbol nearest the field's end position, which is whatever identifier happens to sit there - for `f x`, that is `x`, not the call `f x`. Reads the type the checker captured for the field's whole span instead (TryGetCapturedType), so it matches the expression's own result type regardless of its shape. Surround With reindented every non-first selected line uniformly, including a line that is itself inside a multi-line string continued from an earlier selected line - inserting indentation there changes the string's value, not just its position. classifyLines now threads the lexer's color state across the span and classifies such a line as InsideString, left untouched like a blank line. lock.snippet closed the lambda on the same line as $selected$$end$, so a selection whose last line ends in a trailing // comment swallowed the closing paren into the comment, leaving the call unclosed. Moved onto its own line, matching how the other wrapping snippets already close. Co-Authored-By: Claude Sonnet 5 --- .../snippets/1033/FSharp/lock.snippet | 3 +- .../Snippets/SnippetExpansionClient.fs | 81 ++++++++++++------- .../Snippets/SnippetFunctions.fs | 71 ++++++++-------- .../Snippets/SnippetIndentation.fs | 16 +++- .../Snippets/SnippetIndentationTests.fs | 28 +++++++ 5 files changed, 135 insertions(+), 64 deletions(-) diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet index b666a4bfc22..f03960500bc 100644 --- a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet +++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/lock.snippet @@ -20,7 +20,8 @@ - $selected$$end$)]]> + $selected$$end$ +)]]> diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs index d7fa7ba9a78..a8167d84cc1 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs @@ -17,6 +17,8 @@ open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.Text.Editor open Microsoft.VisualStudio.TextManager.Interop +open FSharp.Compiler.Tokenization + open MSXML [] @@ -44,10 +46,53 @@ module internal SnippetExpansionHelpers = 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 = + /// settles it before falling back to comparing the already-fetched text against known prefixes. + let startsRootLevelDirective (line: ITextSnapshotLine) indent (text: string) = line.Snapshot[line.Start.Position + indent] = '#' - && SnippetIndentation.isRootLevelDirective (line.GetText()) + && SnippetIndentation.isRootLevelDirective text + + /// Scans one line's text from `lexState`, threading the state a later line needs to know whether + /// it opens inside an unfinished string. + let rec private lexStateAfter (tokenizer: FSharpLineTokenizer) lexState = + match tokenizer.ScanToken lexState with + | None, atEndOfLine -> atEndOfLine + | Some _, afterToken -> lexStateAfter tokenizer afterToken + + /// The kind and indentation of every line `FormatSpan` was given, threading the lexer state + /// across them so a `SelectedRest` line that opens inside a string - continuing one that started + /// on an earlier selected line - is left alone rather than reindented into the string's value. + let classifyLines (snapshot: ITextSnapshot) (span: VsTextSpan) selectedLines = + let sourceTokenizer = FSharpSourceTokenizer([], None, None) + let lastLine = min span.iEndLine (snapshot.LineCount - 1) + let mutable lexState = FSharpTokenizerLexState.Initial + + [ + for lineNumber in span.iStartLine .. lastLine -> + let line = snapshot.GetLineFromLineNumber lineNumber + let indent = leadingWhitespaceOf line + let text = line.GetText() + let enteringLexState = lexState + + lexState <- lexStateAfter (sourceTokenizer.CreateLineTokenizer text) lexState + + let kind = + if indent = line.Length then + SnippetIndentation.Blank + elif SnippetIndentation.isInsideString (FSharpLineTokenizer.ColorStateOfLexState enteringLexState) then + SnippetIndentation.InsideString + elif startsRootLevelDirective line indent text 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 + } + ] /// 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 @@ -307,7 +352,9 @@ type internal FSharpSnippetExpansionClient /// /// - 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; + /// the buffer, and needs only the nesting the template adds around the field - except a + /// line that opens inside a string continued from an earlier one, whose whitespace is the + /// string's own value and is left untouched; /// - 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[]) = @@ -329,31 +376,7 @@ type internal FSharpSnippetExpansionClient | 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 - } - ] + let lines = classifyLines snapshot span selectedLines use edit = subjectBuffer.CreateEdit() diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs index 5b21b3af83e..f8c683afaed 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs @@ -111,7 +111,13 @@ module internal SnippetFunctionHelpers = | _ -> ValueSome declaration) ValueNone - return innermost |> ValueOption.map _.LogicalName + // `LogicalName` is qualified by every enclosing module (`Outer.C`), which does not + // resolve from a constructor sitting inside `C`'s own scope. + return + innermost + |> ValueOption.map (fun declaration -> + let name = declaration.LogicalName + name.Substring(name.LastIndexOf('.') + 1)) } /// The type an expression evaluates to: for a call, what is left once its arguments are applied. @@ -125,12 +131,20 @@ module internal SnippetFunctionHelpers = /// so no intermediate collection of rules is ever built. let private matchRulesFor (entity: FSharpEntity) = if entity.IsFSharpUnion then + // A `[]` union rejects a bare case pattern (`A`, not `U.A`) - + // that reads as binding a fresh variable named `A`, not testing the case. + let qualifier = + if entity.HasAttribute() then + $"%s{entity.DisplayName}." + else + "" + entity.UnionCases |> Seq.map (fun case -> if case.HasFields then - $"| %s{case.Name} _ -> ()" + $"| %s{qualifier}%s{case.Name} _ -> ()" else - $"| %s{case.Name} -> ()") + $"| %s{qualifier}%s{case.Name} -> ()") elif entity.IsEnum then seq { for field in entity.FSharpFields do @@ -143,40 +157,33 @@ module internal SnippetFunctionHelpers = 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 = + /// The match rules covering the union or enum `$expression$` evaluates to, or ValueNone for + /// anything else. Reads the type the checker captured for the field's own span rather than the + /// symbol nearest the caret: that resolves to whatever token sits there, which is the wrong type + /// the moment `$expression$` is itself a call - `f x` would resolve the type of `x`, not of `f x`. + let tryGetMatchRules (document: Document) (span: VsTextSpan) = 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 range = + Range.mkRange + document.FilePath + (Position.mkPos (span.iStartLine + 1) span.iStartIndex) + (Position.mkPos (span.iEndLine + 1) span.iEndIndex) + + let position = sourceText.Lines[span.iEndLine].Start + span.iEndIndex 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 + checkResults.TryGetCapturedType range + |> Option.map resultTypeOf + |> Option.bind (fun resultType -> + if resultType.HasTypeDefinition then + Some(matchRulesFor resultType.TypeDefinition) + else + None) + |> Option.defaultValue Seq.empty return match String.Join(sourceText.LineBreakAt position, rules) with @@ -263,9 +270,7 @@ type internal SnippetFunctionGenerateMatchCases(getSession, subjectBuffer: IText 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) + runSynchronously document.Project.FSharpTimeUntilStaleCompletion (tryGetMatchRules document span) | _ -> ValueNone diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs index 9c96e624d5a..e5cd339fa92 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs @@ -4,6 +4,8 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System +open FSharp.Compiler.Tokenization + /// 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 @@ -25,6 +27,9 @@ module internal SnippetIndentation = | SelectedFirst /// A later line of that text. It starts its own buffer line at its original column. | SelectedRest + /// A later line of that text which begins inside a string literal continued from an earlier + /// selected line - its whitespace is part of the string's value, not its layout. + | InsideString /// Whitespace only; left alone so the snippet does not leave trailing spaces behind. | Blank @@ -51,12 +56,21 @@ module internal SnippetIndentation = rootLevelDirectives |> Array.exists (fun directive -> text.StartsWith(directive, StringComparison.Ordinal)) + /// Whether a line beginning in this lexer color state is a continuation of a string literal. + let isInsideString (colorState: FSharpTokenizerColorState) = + match colorState with + | FSharpTokenizerColorState.String + | FSharpTokenizerColorState.VerbatimString + | FSharpTokenizerColorState.TripleQuoteString -> true + | _ -> false + /// 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 + | Blank, _ + | InsideString, _ -> 0 | RootLevelDirective, _ -> -line.Indent | Template, AtCaret column -> if index = 0 then 0 else column | Template, AroundSelection(column, _) -> column diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs index d17787f4d5c..08f42314327 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs @@ -4,6 +4,8 @@ namespace FSharp.Editor.Tests open Xunit +open FSharp.Compiler.Tokenization + 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 @@ -33,6 +35,9 @@ module SnippetIndentationTests = let private selectedRest indent = { Kind = SelectedRest; Indent = indent } + let private insideString indent = + { Kind = InsideString; Indent = indent } + [] let ``Surround With for over two lines nests both under the loop`` () = // fields = [ @@ -99,3 +104,26 @@ module SnippetIndentationTests = let lines = [ template 0; selectedFirst 16; selectedRest 16 ] Assert.Equal([ 12; 16; 20 ], columnsAfter (AroundSelection(12, 4)) lines) + + [] + let ``A line inside a string carried over from the selection is left alone`` () = + // captured <- """a <- selectedFirst, untouched regardless of placement + // b""" <- selectedRest, but "b\"\"\"" is the string's own content + let lines = [ template 0; selectedFirst 0; insideString 0 ] + + Assert.Equal([ 20; 0; 0 ], columnsAfter (AroundSelection(20, 4)) lines) + + [] + [] + [] + [] + let ``A string color state is recognized as a string continuation`` (state: FSharpTokenizerColorState) = + Assert.True(isInsideString state) + + [] + [] + [] + [] + [] + [] + let ``A non-string color state is not`` (state: FSharpTokenizerColorState) = Assert.False(isInsideString state) From 8ade43480b310f4a8617e60f979d040797aa1fe6 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 12:10:58 +0200 Subject: [PATCH 5/6] Resolve the second round of snippet review findings A match on a union or enum now spells its cases with the shortest qualifier that resolves at the insertion point, asked of the checked file the way the simplify-names analyzer asks it, instead of assuming `DisplayName.` is reachable: a `[]` union in an unopened module produced `U.A`, which does not resolve. The expression's captured type is used as it stands, so matching a function value no longer generates the arms of its result type. Indentation is measured in visual columns. A snippet expanded after a tab took the character count as its column and came out one space in, and Surround With over tab-indented code wrapped it at the wrong column. A cancelled Surround With never reports back, so its pending selection was still set when the next shortcut expanded; the shortcut path now clears it. Co-Authored-By: Claude Sonnet 5 --- .../Snippets/SnippetCommandHandler.fs | 5 +- .../Snippets/SnippetExpansionClient.fs | 46 ++++++++--- .../Snippets/SnippetFunctions.fs | 80 ++++++++++++------- .../Snippets/SnippetIndentation.fs | 8 ++ .../Snippets/SnippetIndentationTests.fs | 17 ++++ 5 files changed, 114 insertions(+), 42 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs index 1dc814a7f86..e674a026089 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs @@ -67,6 +67,7 @@ module internal SnippetCommandHelpers = let snapshot = span.Snapshot let firstLine = snapshot.GetLineFromPosition span.Start.Position let lastLine = snapshot.GetLineFromPosition span.End.Position + let tabSize = tabSizeOf textView.Options let column = seq { firstLine.LineNumber .. lastLine.LineNumber } @@ -75,8 +76,8 @@ module internal SnippetCommandHelpers = let line = snapshot.GetLineFromLineNumber lineNumber match leadingWhitespaceOf line with - | indent when indent = line.Length -> narrowest - | indent -> min narrowest indent) + | leadingWhitespace when leadingWhitespace = line.Length -> narrowest + | leadingWhitespace -> min narrowest (visualColumnAt tabSize line leadingWhitespace)) Int32.MaxValue let column = if column = Int32.MaxValue then 0 else column diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs index a8167d84cc1..6dee15a6de7 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs @@ -36,19 +36,34 @@ module internal SnippetExpansionHelpers = width + let tabSizeOf (options: IEditorOptions) = + options.GetOptionValue DefaultOptions.TabSizeOptionId + + /// The column `length` characters into `line`, with tabs counted at the width they render at. + let visualColumnAt tabSize (line: ITextSnapshotLine) length = + let snapshot = line.Snapshot + let start = line.Start.Position + let mutable column = 0 + + for offset in 0 .. length - 1 do + column <- SnippetIndentation.advanceColumn tabSize column snapshot[start + offset] + + column + /// 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 + let tabSize = tabSizeOf options 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 falling back to comparing the already-fetched text against known prefixes. - let startsRootLevelDirective (line: ITextSnapshotLine) indent (text: string) = - line.Snapshot[line.Start.Position + indent] = '#' + /// `leadingWhitespace` is the number of characters before the first non-blank one. + let startsRootLevelDirective (line: ITextSnapshotLine) leadingWhitespace (text: string) = + line.Snapshot[line.Start.Position + leadingWhitespace] = '#' && SnippetIndentation.isRootLevelDirective text /// Scans one line's text from `lexState`, threading the state a later line needs to know whether @@ -61,7 +76,7 @@ module internal SnippetExpansionHelpers = /// The kind and indentation of every line `FormatSpan` was given, threading the lexer state /// across them so a `SelectedRest` line that opens inside a string - continuing one that started /// on an earlier selected line - is left alone rather than reindented into the string's value. - let classifyLines (snapshot: ITextSnapshot) (span: VsTextSpan) selectedLines = + let classifyLines tabSize (snapshot: ITextSnapshot) (span: VsTextSpan) selectedLines = let sourceTokenizer = FSharpSourceTokenizer([], None, None) let lastLine = min span.iEndLine (snapshot.LineCount - 1) let mutable lexState = FSharpTokenizerLexState.Initial @@ -69,18 +84,19 @@ module internal SnippetExpansionHelpers = [ for lineNumber in span.iStartLine .. lastLine -> let line = snapshot.GetLineFromLineNumber lineNumber - let indent = leadingWhitespaceOf line + let leadingWhitespace = leadingWhitespaceOf line + let indent = visualColumnAt tabSize line leadingWhitespace let text = line.GetText() let enteringLexState = lexState lexState <- lexStateAfter (sourceTokenizer.CreateLineTokenizer text) lexState let kind = - if indent = line.Length then + if leadingWhitespace = line.Length then SnippetIndentation.Blank elif SnippetIndentation.isInsideString (FSharpLineTokenizer.ColorStateOfLexState enteringLexState) then SnippetIndentation.InsideString - elif startsRootLevelDirective line indent text then + elif startsRootLevelDirective line leadingWhitespace text then SnippetIndentation.RootLevelDirective else match selectedLines with @@ -225,6 +241,10 @@ type internal FSharpSnippetExpansionClient | null, _ | _, null -> false | expansionManager, viewAdapter -> + // A cancelled Surround With never reports back, so its selection is still pending here. + pendingSurround <- ValueNone + surround <- ValueNone + let spans = [| shortcutSpan |] let mutable path = null let mutable title = null @@ -371,12 +391,16 @@ type internal FSharpSnippetExpansionClient | ValueSome s -> ValueSome(span.iStartLine + s.FieldLine, span.iStartLine + s.FieldLine + s.LineCount - 1) | ValueNone -> ValueNone + let tabSize = tabSizeOf textView.Options + let placement = match surround with | ValueSome s -> SnippetIndentation.AroundSelection(s.Column, s.FieldIndent) - | ValueNone -> SnippetIndentation.AtCaret span.iStartIndex + | ValueNone -> + let startLine = snapshot.GetLineFromLineNumber span.iStartLine + SnippetIndentation.AtCaret(visualColumnAt tabSize startLine span.iStartIndex) - let lines = classifyLines snapshot span selectedLines + let lines = classifyLines tabSize snapshot span selectedLines use edit = subjectBuffer.CreateEdit() @@ -387,7 +411,9 @@ type internal FSharpSnippetExpansionClient 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) + // A negative delta unindents the line entirely, and the indent is measured + // in columns while the edit removes characters. + edit.Delete(line.Start.Position, leadingWhitespaceOf line) |> ignore) edit.Apply() |> ignore diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs index f8c683afaed..2dc39d49bfc 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs @@ -120,36 +120,57 @@ module internal SnippetFunctionHelpers = name.Substring(name.LastIndexOf('.') + 1)) } - /// 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() + /// The qualifier `symbol`, one of `entity`'s cases, needs at `position`: none when the case is in + /// scope, otherwise the shortest path of enclosing names that reaches it. + let private necessaryQualifier (checkResults: FSharpCheckFileResults) position (entity: FSharpEntity) (symbol: FSharpSymbol) = + let path = + match entity.TryGetFullDisplayName() with + | Some fullName -> List.ofArray (fullName.Split '.') + | None -> [ entity.DisplayName ] + + let rec widen remaining qualifier = + if checkResults.IsRelativeNameResolvableFromSymbol(position, qualifier, symbol) then + qualifier + else + match remaining with + | [] -> qualifier + | next :: rest -> widen rest (next :: qualifier) + + widen (List.rev path) [] + + /// `symbol`'s qualifier spelled as a prefix of a pattern. + let private qualifierPrefix checkResults position entity symbol = + match necessaryQualifier checkResults position entity symbol with + | [] -> "" + | qualifier -> String.Join(".", qualifier) + "." /// 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) = + let private matchRulesFor checkResults position (entity: FSharpEntity) = if entity.IsFSharpUnion then - // A `[]` union rejects a bare case pattern (`A`, not `U.A`) - - // that reads as binding a fresh variable named `A`, not testing the case. - let qualifier = - if entity.HasAttribute() then - $"%s{entity.DisplayName}." - else - "" + let prefix = + match Seq.tryHeadV entity.UnionCases with + | ValueSome first -> qualifierPrefix checkResults position entity first + | ValueNone -> "" entity.UnionCases |> Seq.map (fun case -> if case.HasFields then - $"| %s{qualifier}%s{case.Name} _ -> ()" + $"| %s{prefix}%s{case.Name} _ -> ()" else - $"| %s{qualifier}%s{case.Name} -> ()") + $"| %s{prefix}%s{case.Name} -> ()") elif entity.IsEnum then + let literals = + entity.FSharpFields |> Seq.filter (fun field -> field.LiteralValue.IsSome) + + let prefix = + match Seq.tryHeadV literals with + | ValueSome first -> qualifierPrefix checkResults position entity first + | ValueNone -> "" + seq { - for field in entity.FSharpFields do - if field.LiteralValue.IsSome then - $"| %s{entity.DisplayName}.%s{field.Name} -> ()" + for field in literals do + $"| %s{prefix}%s{field.Name} -> ()" // An enum value need not be one of the declared literals, so the wildcard is not optional. "| _ -> ()" @@ -157,10 +178,8 @@ module internal SnippetFunctionHelpers = else Seq.empty - /// The match rules covering the union or enum `$expression$` evaluates to, or ValueNone for - /// anything else. Reads the type the checker captured for the field's own span rather than the - /// symbol nearest the caret: that resolves to whatever token sits there, which is the wrong type - /// the moment `$expression$` is itself a call - `f x` would resolve the type of `x`, not of `f x`. + /// The match rules covering the union or enum `$expression$` evaluates to, or ValueNone for any + /// other type. let tryGetMatchRules (document: Document) (span: VsTextSpan) = cancellableTask { let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName @@ -176,14 +195,15 @@ module internal SnippetFunctionHelpers = let position = sourceText.Lines[span.iEndLine].Start + span.iEndIndex let rules = - checkResults.TryGetCapturedType range - |> Option.map resultTypeOf - |> Option.bind (fun resultType -> - if resultType.HasTypeDefinition then - Some(matchRulesFor resultType.TypeDefinition) + match checkResults.TryGetCapturedType range with + | Some fsharpType -> + let fsharpType = fsharpType.StripAbbreviations() + + if fsharpType.HasTypeDefinition then + matchRulesFor checkResults range.Start fsharpType.TypeDefinition else - None) - |> Option.defaultValue Seq.empty + Seq.empty + | None -> Seq.empty return match String.Join(sourceText.LineBreakAt position, rules) with diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs index e5cd339fa92..83fe8f77fa3 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs @@ -33,6 +33,7 @@ module internal SnippetIndentation = /// Whitespace only; left alone so the snippet does not leave trailing spaces behind. | Blank + /// `Indent` is a visual column, so a tab counts as the width it renders at. type Line = { Kind: LineKind; Indent: int } /// How the snippet got there, which is what supplies the column to align to. @@ -56,6 +57,13 @@ module internal SnippetIndentation = rootLevelDirectives |> Array.exists (fun directive -> text.StartsWith(directive, StringComparison.Ordinal)) + /// The column after `character` is written at `column`: a tab runs on to the next tab stop. + let advanceColumn tabSize column character = + if character = '\t' then + column + tabSize - column % tabSize + else + column + 1 + /// Whether a line beginning in this lexer color state is a continuation of a string literal. let isInsideString (colorState: FSharpTokenizerColorState) = match colorState with diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs index 08f42314327..9462f1c9b6a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs @@ -86,6 +86,23 @@ module SnippetIndentationTests = Assert.Equal([ 8; 12; 8 ], columnsAfter (AtCaret 8) lines) + [] + [] + [] + [] + [] + let ``A tab runs on to the next tab stop and any other character takes one column`` + (tabSize: int, column: int, character: char, expected: int) + = + Assert.Equal(expected, advanceColumn tabSize column character) + + [] + let ``Insert Snippet after a tab nests the body by the tab's width`` () = + // if| + Tab: the body sits one level in from the `if`, which itself starts at column 4. + let column = "\t" |> Seq.fold (advanceColumn 4) 0 + + Assert.Equal([ 4; 8; 4 ], columnsAfter (AtCaret column) [ template 4; template 4; template 0 ]) + [] let ``Insert Snippet still pins a directive to column zero`` () = let lines = [ directive 8; template 4; directive 0 ] From 080f662efc9d183986cd309f72a9d42d5dae365b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 13:24:29 +0200 Subject: [PATCH 6/6] Add a backgroundTask snippet Expands and surrounds like `task`, for code that has to run off the caller's context. Co-Authored-By: Claude Sonnet 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- .../1033/FSharp/backgroundTask.snippet | 20 +++++++++++++++++++ .../Snippets/SnippetCatalogTests.fs | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 47927c6d29f..f446fbf71d3 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. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521)) +* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 41 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521)) ### Fixed diff --git a/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet new file mode 100644 index 00000000000..5f6c39d5b88 --- /dev/null +++ b/vsintegration/Vsix/VisualFSharpFull/snippets/1033/FSharp/backgroundTask.snippet @@ -0,0 +1,20 @@ + + + +
+ backgroundTask + backgroundTask + Code snippet for a backgroundTask expression + Microsoft Corporation + + Expansion + SurroundsWith + +
+ + + +
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs index df538701405..4e957dffbc2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs @@ -132,7 +132,7 @@ type SnippetCatalogTests() = [] member _.``The catalog ships the snippets the registration promises``() = - Assert.Equal(40, SnippetCatalog.files.Length) + Assert.Equal(41, SnippetCatalog.files.Length) Assert.True(File.Exists SnippetCatalog.indexPath, $"missing {SnippetCatalog.indexPath}") []