From 1c05e65ad17a274574fefc155bf00d0749269d59 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:48 +0200 Subject: [PATCH 1/9] 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 036d94570c868f62ba1d8f9c3d139f661b27d8f4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 09:41:55 +0200 Subject: [PATCH 2/9] 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 52bbf6f7bc5..8e332e982d6 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -3,6 +3,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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) +* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 79ad4e213a0..12699c88113 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 81ed0f1e0c04ed01b2c1f69c144199ef67e0d7d9 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 11:50:23 +0200 Subject: [PATCH 3/9] Link the snippets release note to its issue and pull request Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 8e332e982d6..49fc9cfd090 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,6 +4,7 @@ * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. +* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521)) ### Fixed From bec844c5b9c3c359e915d981014fddef72e3c34e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 18:54:49 +0200 Subject: [PATCH 4/9] 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 78a939c998738f827bd4ba8c1eb914f857deeb5a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 12:10:58 +0200 Subject: [PATCH 5/9] 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 12054509b7f7298d1d9c7f26d909d7d690e54ace Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 13:24:29 +0200 Subject: [PATCH 6/9] 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 | 1 + .../1033/FSharp/backgroundTask.snippet | 20 +++++++++++++++++++ .../Snippets/SnippetCatalogTests.fs | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) 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 49fc9cfd090..96ab53cd5e8 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,6 +5,7 @@ * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. * Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 40 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521)) +* Code snippets for F#: **Insert Snippet** (Ctrl+K,Ctrl+X), **Surround With** (Ctrl+K,Ctrl+S), Tab expansion of a snippet shortcut, and 41 built-in snippets listed under **Tools ▸ Code Snippets Manager**. `ctor` and `equals` fill in the enclosing type name, and `match` generates the cases of the union or enum it is given. ([Issue #1498](https://github.com/dotnet/fsharp/issues/1498), [PR #20521](https://github.com/dotnet/fsharp/pull/20521)) ### Fixed diff --git a/vsintegration/Vsix/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}") [] From f981b8d7c4126254285f045bbb9b9f4f506ac8fa Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 20:39:22 +0200 Subject: [PATCH 7/9] Test the snippet functions against a real document The catalog test substitutes each literal's XML default, so it never ran `ClassName()` or `GenerateMatchCases()`. The match-rule core now takes the range of the expression, which lets a test hand it a document and a range without the expansion engine's COM span, and the new tests cover the names and case spellings the earlier review rounds found wrong: a type in nested modules, qualified and unqualified unions and enums, a call, a function value and a plain value. Co-Authored-By: Claude Sonnet 5 --- .../Snippets/SnippetFunctions.fs | 22 +-- .../FSharp.Editor.Tests.fsproj | 1 + .../Snippets/SnippetFunctionTests.fs | 161 ++++++++++++++++++ 3 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs index 2dc39d49bfc..f25fce0fdfd 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs @@ -178,21 +178,15 @@ module internal SnippetFunctionHelpers = else Seq.empty - /// The match rules covering the union or enum `$expression$` evaluates to, or ValueNone for any - /// other type. - let tryGetMatchRules (document: Document) (span: VsTextSpan) = + /// The match rules covering the union or enum that the expression at `range` evaluates to, or + /// ValueNone for any other type. + let tryGetMatchRulesAt (document: Document) (range: range) = cancellableTask { let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct - 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 position = sourceText.Lines[range.EndLine - 1].Start + range.EndColumn let rules = match checkResults.TryGetCapturedType range with @@ -211,6 +205,14 @@ module internal SnippetFunctionHelpers = | rules -> ValueSome rules } + /// `tryGetMatchRulesAt` for the span a snippet field occupies. + let tryGetMatchRules (document: Document) (span: VsTextSpan) = + Range.mkRange + document.FilePath + (Position.mkPos (span.iStartLine + 1) span.iStartIndex) + (Position.mkPos (span.iEndLine + 1) span.iEndIndex) + |> tryGetMatchRulesAt document + /// 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. [] diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index 86f06113035..3b5b6297b4b 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -85,6 +85,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs new file mode 100644 index 00000000000..b5d182b2f4f --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System + +open Xunit + +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks + +open FSharp.Compiler.Text + +open FSharp.Editor.Tests.Helpers + +/// `ClassName()` and `GenerateMatchCases()` against a real document and its check results, the way the +/// expansion engine calls them. +module internal SnippetFunctionTestHelpers = + + let documentOf (source: string) = + RoslynTestHelpers.CreateSolution(source) |> RoslynTestHelpers.GetSingleDocument + + let containingTypeName (source: string) = + let position = source.IndexOf("Marked", StringComparison.Ordinal) + + tryGetContainingTypeName (documentOf source) position + |> CancellableTask.runSynchronouslyWithoutCancellation + + /// The rules generated for `match with`, one per element. + let matchRules (context: string) (expression: string) = + let source = $"{context}\nlet run () =\n match {expression} with\n | _ -> 0\n" + + let document = documentOf source + let text = SourceText.From source + + let positionOf offset = + let linePosition = text.Lines.GetLinePosition offset + Position.mkPos (linePosition.Line + 1) linePosition.Character + + let start = + source.IndexOf($"match {expression} with", StringComparison.Ordinal) + + "match ".Length + + let range = + Range.mkRange document.FilePath (positionOf start) (positionOf (start + expression.Length)) + + tryGetMatchRulesAt document range + |> CancellableTask.runSynchronouslyWithoutCancellation + |> ValueOption.map (fun rules -> rules.Split([| '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries)) + +type SnippetFunctionTests() = + + static member typeNames: obj[][] = + [| + [| "a type at the top level"; "type C() =\n member _.Marked = 0"; "C" |] + [| + "a type in a module" + "module Outer =\n type C() =\n member _.Marked = 0" + "C" + |] + [| + "a type in nested modules" + "module A =\n module B =\n type C() =\n member _.Marked = 0" + "C" + |] + [| + "the type the position is in, of two" + "type First() =\n member _.Value = 0\n\ntype Second() =\n member _.Marked = 0" + "Second" + |] + |] + + static member matchCases: obj[][] = + [| + [| + "a union in scope" + "type U = A | B\nlet value = U.B" + "value" + [| "| A -> ()"; "| B -> ()" |] + |] + [| + "a union with fields" + "type Shape = Circle of int | Square of int * int | Empty\nlet value = Empty" + "value" + [| "| Circle _ -> ()"; "| Square _ -> ()"; "| Empty -> ()" |] + |] + [| + "a RequireQualifiedAccess union in scope" + "[]\ntype U = A | B\nlet value = U.B" + "value" + [| "| U.A -> ()"; "| U.B -> ()" |] + |] + [| + "a RequireQualifiedAccess union in a module that is not open" + "module Outer =\n []\n type U = A | B\n\nlet value = Outer.U.B" + "value" + [| "| Outer.U.A -> ()"; "| Outer.U.B -> ()" |] + |] + [| + "a union in a module that is not open" + "module Outer =\n type U = A | B\n\nlet value = Outer.U.B" + "value" + [| "| Outer.U.A -> ()"; "| Outer.U.B -> ()" |] + |] + [| + "a union in a module that is open" + "module Outer =\n type U = A | B\n\nopen Outer\nlet value = U.B" + "value" + [| "| A -> ()"; "| B -> ()" |] + |] + [| + "an enum in a module that is not open" + "module Outer =\n type E = | X = 1 | Y = 2\n\nlet value = Outer.E.X" + "value" + [| "| Outer.E.X -> ()"; "| Outer.E.Y -> ()"; "| _ -> ()" |] + |] + [| + "a call, whose result is what is matched" + "type Input = X | Y\ntype Output = A | B\nlet make (_: Input) = B\nlet value = Y" + "make value" + [| "| A -> ()"; "| B -> ()" |] + |] + |] + + static member noMatchCases: obj[][] = + [| + [| "a function value"; "type U = A | B\nlet make (_: int) = B"; "make" |] + [| "a type that is neither a union nor an enum"; ""; "1" |] + |] + + [] + [] + member _.``ClassName names the type the snippet lands in, without its enclosing modules`` + (_name: string, source: string, expected: string) + = + Assert.Equal( + expected, + SnippetFunctionTestHelpers.containingTypeName source + |> ValueOption.defaultValue "no type" + ) + + [] + member _.``ClassName has no answer outside a type``() = + Assert.True((SnippetFunctionTestHelpers.containingTypeName "let Marked = 1").IsNone) + + [] + [] + member _.``GenerateMatchCases spells each case the way it resolves at the match`` + (_name: string, context: string, expression: string, expected: string[]) + = + match SnippetFunctionTestHelpers.matchRules context expression with + | ValueSome rules -> Assert.Equal(List.ofArray expected, List.ofArray rules) + | ValueNone -> failwith "no match rules were generated" + + [] + [] + member _.``GenerateMatchCases leaves the default alone when the expression is not a union or enum`` + (_name: string, context: string, expression: string) + = + Assert.True((SnippetFunctionTestHelpers.matchRules context expression).IsNone) From 7797f9ddcc6f4b20d8a5cd17b3ac994400912713 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 20:06:39 +0200 Subject: [PATCH 8/9] Share one line-break lookup between the snippets and the signature code fix `SourceText.LineBreakAt` now walks back to the nearest line that has a break, the way the code fix's private helper did, so a match inserted on the last line of a CRLF file no longer falls back to the host's line ending. The code fix uses it instead of its own copy. Co-Authored-By: Claude Opus 5.5 --- .../AddMissingAttributeToSignature.fs | 23 +------------------ .../src/FSharp.Editor/Common/Extensions.fs | 12 +++++----- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs b/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs index feeb3b10722..e49abce9fb1 100644 --- a/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs +++ b/vsintegration/src/FSharp.Editor/CodeFixes/AddMissingAttributeToSignature.fs @@ -94,27 +94,6 @@ type internal AddMissingAttributeToSignatureCodeFixProvider [ 0 then - Some(sigSourceText.ToString(TextSpan(line.End, lbLen))) - else - None - - let lines = sigSourceText.Lines - let startLineNo = lines.GetLineFromPosition(lineStart).LineNumber - let mutable result: string option = None - let mutable i = startLineNo - - while result.IsNone && i >= 0 do - result <- lineBreakOf lines.[i] - i <- i - 1 - - result |> Option.defaultValue Environment.NewLine - // Returns None if the .fsi was truncated between registration and apply. let tryFSharpRangeToTextSpan (text: SourceText) (range: FSharp.Compiler.Text.range) = try @@ -204,7 +183,7 @@ type internal AddMissingAttributeToSignatureCodeFixProvider [ let currentLineStart = current.Lines.GetLineFromPosition(currentSigSpan.Start).Start let currentIndent = indentOfLine current currentLineStart - let currentLineBreak = lineBreakAt current currentLineStart + let currentLineBreak = current.LineBreakAt currentLineStart let currentInsertion = $"{currentIndent}{bracketed}{currentLineBreak}" let updated = diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 12699c88113..5b7e120e94a 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,14 +296,14 @@ 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 + let rec lineBreakOf (line: TextLine) = + match this.ToString(TextSpan(line.End, line.EndIncludingLineBreak - line.End)) with + | "" when line.LineNumber > 0 -> lineBreakOf this.Lines[line.LineNumber - 1] + | "" -> Environment.NewLine + | lineBreak -> lineBreak - match this.ToString(TextSpan(line.End, line.EndIncludingLineBreak - line.End)) with - | "" -> Environment.NewLine - | lineBreak -> lineBreak + lineBreakOf (this.Lines.GetLineFromPosition position) type NavigationItem with From b542aeeef072e48e4915dcda10bd7086e2fa53a0 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 20:06:40 +0200 Subject: [PATCH 9/9] Compact the snippet support - Directive and string-continuation lines are what the F# lexer says they are: the hand-written directive prefixes and lexer-state list are gone. The snippet's own lines and the selected ones are lexed apart, so a region the selection leaves open no longer moves the wrapper's `#endif`, and `$$"""` continuations are recognised. - Surround With takes the wrapper's column from the selected code only, not from directive lines or string content in it. - `FormatSpan` delegates to a `Reindent` built from named steps instead of a narrated comment. - `ClassName()` walks the parse tree with `ParsedInput.tryPickLast`, and also answers in signature files; `GenerateMatchCases()` uses `DisplayName`, so cases that need backticks get them. - Editor options, out-parameter tuples, `RoslynHelpers` range conversions and `tryGetDocument` are reused instead of re-implemented; the pending Surround With selection is passed along rather than reset in three places. - Comments that restated the code are removed; tests are table-driven. Co-Authored-By: Claude Opus 5.5 --- vsintegration/src/FSharp.Editor/Common/Vs.fs | 9 +- .../Snippets/SnippetCommandHandler.fs | 89 ++--- .../Snippets/SnippetExpansionClient.fs | 256 ++++-------- .../Snippets/SnippetFunctions.fs | 131 ++---- .../Snippets/SnippetIndentation.fs | 81 ++-- .../Snippets/SnippetCatalogTests.fs | 375 +++++++----------- .../Snippets/SnippetFunctionTests.fs | 251 ++++++------ .../Snippets/SnippetIndentationTests.fs | 264 ++++++------ 8 files changed, 585 insertions(+), 871 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Common/Vs.fs b/vsintegration/src/FSharp.Editor/Common/Vs.fs index 2c84fef73c8..4bd00effc64 100644 --- a/vsintegration/src/FSharp.Editor/Common/Vs.fs +++ b/vsintegration/src/FSharp.Editor/Common/Vs.fs @@ -101,12 +101,9 @@ module internal ServiceProviderExtensions = match sp.GetService() with | null -> null | textManager -> - let mutable expansionManager = Unchecked.defaultof - - if Com.Succeeded(textManager.GetExpansionManager(&expansionManager)) then - expansionManager - else - null + match textManager.GetExpansionManager() with + | hr, expansionManager when Com.Succeeded hr -> expansionManager + | _ -> null member sp.RunningDocumentTable = sp.GetService() diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs index e674a026089..8fb08cda3dd 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetCommandHandler.fs @@ -2,11 +2,9 @@ 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 @@ -14,10 +12,9 @@ open Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.Text.Editor open Microsoft.VisualStudio.Text.Editor.Commanding.Commands +open Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods open Microsoft.VisualStudio.Utilities -open FSharp.Compiler.EditorServices - open CancellableTasks [] @@ -26,9 +23,8 @@ 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`. + /// 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) @@ -39,8 +35,6 @@ module internal SnippetCommandHelpers = | _ -> 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. @@ -59,36 +53,28 @@ module internal SnippetCommandHelpers = /// 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 tabSize = tabSizeOf textView.Options - - let column = - seq { firstLine.LineNumber .. lastLine.LineNumber } - |> Seq.fold - (fun narrowest lineNumber -> - let line = snapshot.GetLineFromLineNumber lineNumber + let firstLine = snapshot.GetLineFromPosition(span.Start.Position).LineNumber + let lastLine = snapshot.GetLineFromPosition(span.End.Position).LineNumber - match leadingWhitespaceOf line with - | leadingWhitespace when leadingWhitespace = line.Length -> narrowest - | leadingWhitespace -> min narrowest (visualColumnAt tabSize line leadingWhitespace)) - Int32.MaxValue + let lines = + [ + for lineNumber in firstLine..lastLine -> snapshot.GetLineFromLineNumber(lineNumber).GetText() + ] - let column = if column = Int32.MaxValue then 0 else column + let column = + SnippetIndentation.classify (textView.Options.GetTabSize()) (ValueSome(0, lines.Length - 1)) lines + |> List.filter (fun line -> line.Kind.IsSelectedFirst || line.Kind.IsSelectedRest) + |> List.map _.Indent + |> function + | [] -> 0 + | indents -> List.min indents - column, lastLine.LineNumber - firstLine.LineNumber + 1 + column, lines.Length -/// 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. +/// Ordered after the completion handler so that Tab still commits an open completion list first. [)>] [] [] @@ -104,31 +90,25 @@ type internal FSharpSnippetCommandHandler [] (editorAdapte ) | _ -> 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. + let caret = args.TextView.Caret.Position.BufferPosition.Position + + tryGetDocument args.SubjectBuffer + |> ValueOption.bind (fun document -> runSynchronously parseTimeout (tryGetShortcutAt document caret)) + |> ValueOption.exists (fun (shortcut, range) -> + let shortcutSpan = + VsTextSpan( + iStartLine = range.StartLine - 1, + iStartIndex = range.StartColumn, + iEndLine = range.EndLine - 1, + iEndIndex = range.EndColumn + ) + + client.TryInsertExpansionForShortcut(shortcut, shortcutSpan)) + interface INamed with member _.DisplayName = Constants.FSharpSnippetsCommandHandler @@ -146,8 +126,7 @@ type internal FSharpSnippetCommandHandler [] (editorAdapte 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. + // The buffer is left alone: the expansion engine reads the selection off the view to fill `$selected$`. member _.ExecuteCommand(args, _) = trimSelectedLineBreak args.TextView let column, lineCount = selectionShape args.TextView diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs index 6dee15a6de7..3f1fdf6c420 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetExpansionClient.fs @@ -2,10 +2,6 @@ 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 @@ -15,17 +11,14 @@ open Microsoft.VisualStudio.FSharp.Editor.DebugHelpers open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.Text.Editor +open Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods open Microsoft.VisualStudio.TextManager.Interop -open FSharp.Compiler.Tokenization - 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 @@ -36,83 +29,16 @@ 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 + if options.IsConvertTabsToSpacesEnabled() then String(' ', width) else - let tabSize = tabSizeOf options + let tabSize = options.GetTabSize() 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. - /// `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 - /// 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 tabSize (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 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 leadingWhitespace = line.Length then - SnippetIndentation.Blank - elif SnippetIndentation.isInsideString (FSharpLineTokenizer.ColorStateOfLexState enteringLexState) then - SnippetIndentation.InsideString - elif startsRootLevelDirective line leadingWhitespace 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 - /// expansion session will not report it, so it is read from the file the picker named. + /// column the template indents it to. The expansion session will not report it, so it is read + /// from the file the picker named. let tryReadSelectedFieldLayout (path: string) = try XDocument.Load(path).Descendants() @@ -130,7 +56,6 @@ module internal SnippetExpansionHelpers = FSharpOutputPane.logException e ValueNone - /// Splits `GenerateMatchCases($expression$)` into its name and its `$field$` arguments. let tryParseFunctionCall (call: string) = match call.IndexOf('(') with | -1 -> ValueNone @@ -156,13 +81,10 @@ type internal SurroundLayout = 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) = @@ -173,8 +95,9 @@ type internal FSharpSnippetExpansionClient /// 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 + /// What the open picker was invoked over, before the template is known: `(column, lineCount)` + /// for Surround With, ValueNone for Insert Snippet. + let mutable pendingSurround: (int * int) voption = ValueNone /// The same, completed with the chosen template's layout once the picker has answered. ValueNone /// for Insert Snippet, where the caret column is the whole answer. @@ -188,17 +111,9 @@ type internal FSharpSnippetExpansionClient | 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. + /// `tsInsertPos` is the range `InsertNamedExpansion` *replaces*, so handing it the selection + /// deletes the text a SurroundsWith snippet was meant to wrap. The engine reads the selection off + /// the view it was given in `InvokeInsertionUI` to fill `$selected$`. member private _.TryGetCaretSpan() = if not (obj.ReferenceEquals(textView.TextBuffer, subjectBuffer)) then // Nothing projects F# today; bail out rather than guess at a mapping. @@ -210,13 +125,11 @@ type internal FSharpSnippetExpansionClient 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. + member private this.InsertNamedExpansion(title, path, insertionSpan: VsTextSpan, selection) = + match editorAdapters.GetBufferAdapter subjectBuffer with + | :? IVsExpansion as expansion -> surround <- - match pendingSurround, tryReadSelectedFieldLayout path with + match selection, tryReadSelectedFieldLayout path with | ValueSome(column, lineCount), ValueSome(fieldLine, fieldIndent) -> ValueSome { @@ -228,58 +141,44 @@ type internal FSharpSnippetExpansionClient | _ -> ValueNone indentPending <- true - let mutable session = Unchecked.defaultof - let hr = - expansion.InsertNamedExpansion(title, path, insertionSpan, this, languageGuid, 0, &session) + let hr, _session = + expansion.InsertNamedExpansion(title, path, insertionSpan, this, languageGuid, 0) - not (ErrorHandler.Failed hr) + ErrorHandler.Succeeded hr + | _ -> false - /// 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 -> - // 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 - 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]) + match expansionManager.GetExpansionByShortcut(this, languageGuid, shortcut, viewAdapter, spans, 0) with + | _, null, _ -> false + | hr, path, title -> + ErrorHandler.Succeeded hr + && this.InsertNamedExpansion(title, path, spans[0], ValueNone) - /// Shows a snippet picker. It is not modal: the chosen item comes back later through `OnItemChosen`. - member private this.InvokeInsertionUI(types: string[], prompt) = + /// It is not modal: the chosen item comes back later through `OnItemChosen`. + member private this.InvokeInsertionUI(types: string[], prompt, selection) = match ServiceProvider.GlobalProvider.ExpansionManager, editorAdapters.GetViewAdapter textView with | null, _ | _, null -> false | expansionManager, viewAdapter -> + pendingSurround <- selection + let hr = expansionManager.InvokeInsertionUI(viewAdapter, this, languageGuid, types, types.Length, 1, null, 0, 0, prompt, null) not (ErrorHandler.Failed hr) member this.TryInsertSnippet() = - pendingSurround <- ValueNone - surround <- ValueNone - this.InvokeInsertionUI([| "Expansion"; "SurroundsWith" |], SR.InsertSnippet()) + this.InvokeInsertionUI([| "Expansion"; "SurroundsWith" |], SR.InsertSnippet(), ValueNone) - /// `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()) + this.InvokeInsertionUI([| "SurroundsWith" |], SR.SurroundWith(), ValueSome(column, lineCount)) member private _.EndSession(leaveCaret) = match expansionSession with @@ -321,6 +220,47 @@ type internal FSharpSnippetExpansionClient else false + member private _.Reindent(span: VsTextSpan) = + let snapshot = subjectBuffer.CurrentSnapshot + let tabSize = textView.Options.GetTabSize() + + let lines = + [ + for lineNumber in span.iStartLine .. min span.iEndLine (snapshot.LineCount - 1) -> snapshot.GetLineFromLineNumber lineNumber + ] + + let texts = lines |> List.map _.GetText() + + // `GetFieldSpan "selected"` does not answer for that special literal. + let placement, selectedLines = + match surround with + | ValueSome layout -> + SnippetIndentation.AroundSelection(layout.Column, layout.FieldIndent), + ValueSome(layout.FieldLine, layout.FieldLine + layout.LineCount - 1) + | ValueNone -> + let caretColumn = + texts.Head + |> Seq.take span.iStartIndex + |> Seq.fold (SnippetIndentation.advanceColumn tabSize) 0 + + SnippetIndentation.AtCaret caretColumn, ValueNone + + use edit = subjectBuffer.CreateEdit() + + SnippetIndentation.classify tabSize selectedLines texts + |> SnippetIndentation.deltas placement + |> List.iter2 + (fun (line: ITextSnapshotLine) delta -> + match delta with + | 0 -> () + | indent when indent > 0 -> edit.Insert(line.Start.Position, indentTextOf textView.Options indent) |> ignore + // A negative delta unindents the line entirely, and the indent is measured + // in columns while the edit removes characters. + | _ -> edit.Delete(line.Start.Position, leadingWhitespaceOf line) |> ignore) + lines + + edit.Apply() |> ignore + interface IVsExpansionClient with member _.IsValidType(_buffer, _ts, _rgTypes, _iCountTypes, pfIsValidType: byref) = @@ -341,13 +281,11 @@ type internal FSharpSnippetExpansionClient 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 + | ValueSome span -> this.InsertNamedExpansion(pszTitle, pszPath, span, pendingSurround) |> ignore | ValueNone -> () VSConstants.S_OK @@ -366,55 +304,9 @@ type internal FSharpSnippetExpansionClient 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 - 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[]) = + member this.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 tabSize = tabSizeOf textView.Options - - let placement = - match surround with - | ValueSome s -> SnippetIndentation.AroundSelection(s.Column, s.FieldIndent) - | ValueNone -> - let startLine = snapshot.GetLineFromLineNumber span.iStartLine - SnippetIndentation.AtCaret(visualColumnAt tabSize startLine span.iStartIndex) - - let lines = classifyLines tabSize snapshot span selectedLines - - 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 - // 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 + this.Reindent(ts[0]) VSConstants.S_OK diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs index f25fce0fdfd..4e15aa9b04b 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetFunctions.fs @@ -14,8 +14,8 @@ open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.TextManager.Interop open FSharp.Compiler.CodeAnalysis -open FSharp.Compiler.EditorServices open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax open FSharp.Compiler.Text open CancellableTasks @@ -32,38 +32,25 @@ module internal SnippetFunctionHelpers = [] 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) = + let private tryGetSpan (session: IVsExpansionSession) (getSpan: IVsExpansionSession -> VsTextSpan[] -> int) = match session with | null -> ValueNone | session -> let spans = Array.zeroCreate 1 - if Com.Succeeded(session.GetSnippetSpan spans) then + if Com.Succeeded(getSpan session spans) then ValueSome spans[0] else ValueNone - let tryGetFieldSpan (session: IVsExpansionSession) field = - match session with - | null -> ValueNone - | session -> - let spans = Array.zeroCreate 1 + let tryGetSnippetSpan session = + tryGetSpan session (fun session spans -> session.GetSnippetSpan spans) - if Com.Succeeded(session.GetFieldSpan(field, spans)) then - ValueSome spans[0] - else - ValueNone + let tryGetFieldSpan session field = + tryGetSpan session (fun session spans -> session.GetFieldSpan(field, spans)) /// The expansion engine calls `IVsExpansionFunction` synchronously on the UI thread while the - /// session is live, so there is nowhere to await. `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. + /// session is live, so there is nowhere to await. let runSynchronously millisecondsTimeout (work: CancellableTask<'T voption>) = use cts = new CancellationTokenSource(millisecondsTimeout: int) @@ -72,7 +59,7 @@ module internal SnippetFunctionHelpers = 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. + // Studio code. | e -> FSharpOutputPane.logException e ValueNone @@ -83,45 +70,23 @@ module internal SnippetFunctionHelpers = | document when document.Project.IsFSharp -> ValueSome document | _ -> ValueNone - /// The name of the innermost type declaration whose body contains `position`. - let tryGetContainingTypeName (document: Document) position = + let tryGetContainingTypeName (document: Document) pos = 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 - - // `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)) + (pos, parseResults.ParseTree) + ||> ParsedInput.tryPickLast (fun _ node -> + match node with + // The walk offers every type of a `type … and …` group, and the nearest declaration + // to the left when none contains `pos`. + | SyntaxNode.SynTypeDefn(SynTypeDefn(typeInfo = typeInfo; range = m)) + | SyntaxNode.SynTypeDefnSig(SynTypeDefnSig(typeInfo = typeInfo; range = m)) when Range.rangeContainsPos m pos -> + typeInfo.LongIdent |> List.tryLast |> Option.map _.idText + | _ -> None) + |> ValueOption.ofOption } - /// 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 @@ -138,14 +103,11 @@ module internal SnippetFunctionHelpers = 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 checkResults position (entity: FSharpEntity) = if entity.IsFSharpUnion then let prefix = @@ -156,9 +118,9 @@ module internal SnippetFunctionHelpers = entity.UnionCases |> Seq.map (fun case -> if case.HasFields then - $"| %s{prefix}%s{case.Name} _ -> ()" + $"| %s{prefix}%s{case.DisplayName} _ -> ()" else - $"| %s{prefix}%s{case.Name} -> ()") + $"| %s{prefix}%s{case.DisplayName} -> ()") elif entity.IsEnum then let literals = entity.FSharpFields |> Seq.filter (fun field -> field.LiteralValue.IsSome) @@ -170,7 +132,7 @@ module internal SnippetFunctionHelpers = seq { for field in literals do - $"| %s{prefix}%s{field.Name} -> ()" + $"| %s{prefix}%s{field.DisplayName} -> ()" // An enum value need not be one of the declared literals, so the wildcard is not optional. "| _ -> ()" @@ -178,49 +140,35 @@ module internal SnippetFunctionHelpers = else Seq.empty - /// The match rules covering the union or enum that the expression at `range` evaluates to, or - /// ValueNone for any other type. let tryGetMatchRulesAt (document: Document) (range: range) = cancellableTask { let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct - let position = sourceText.Lines[range.EndLine - 1].Start + range.EndColumn - let rules = - match checkResults.TryGetCapturedType range with - | Some fsharpType -> - let fsharpType = fsharpType.StripAbbreviations() + match checkResults.TryGetCapturedType range |> Option.map _.StripAbbreviations() with + | Some fsharpType when fsharpType.HasTypeDefinition -> matchRulesFor checkResults range.Start fsharpType.TypeDefinition + | _ -> Seq.empty - if fsharpType.HasTypeDefinition then - matchRulesFor checkResults range.Start fsharpType.TypeDefinition - else - Seq.empty - | None -> Seq.empty + let lineBreak = + sourceText.LineBreakAt(RoslynHelpers.FSharpRangeToTextSpan(sourceText, range).End) return - match String.Join(sourceText.LineBreakAt position, rules) with + match String.Join(lineBreak, rules) with | "" -> ValueNone | rules -> ValueSome rules } - /// `tryGetMatchRulesAt` for the span a snippet field occupies. let tryGetMatchRules (document: Document) (span: VsTextSpan) = - Range.mkRange - document.FilePath - (Position.mkPos (span.iStartLine + 1) span.iStartIndex) - (Position.mkPos (span.iEndLine + 1) span.iEndIndex) + Range.mkRange document.FilePath (Position.fromZ span.iStartLine span.iStartIndex) (Position.fromZ span.iEndLine span.iEndIndex) |> tryGetMatchRulesAt document -/// 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[]) = +type internal FSharpSnippetFunction(getSession: unit -> IVsExpansionSession, arguments: string[]) = /// The engine can build a function before it opens the session, so this is read per call. member _.Session = getSession () - member _.SubjectBuffer = subjectBuffer abstract TryGetValue: unit -> string voption @@ -263,27 +211,18 @@ type internal FSharpSnippetFunction(getSession: unit -> IVsExpansionSession, sub 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) + inherit FSharpSnippetFunction(getSession, 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) + runSynchronously parseTimeout (tryGetContainingTypeName document (Position.fromZ span.iStartLine span.iStartIndex)) | _ -> 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) + inherit FSharpSnippetFunction(getSession, 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 '$') @@ -292,7 +231,5 @@ type internal SnippetFunctionGenerateMatchCases(getSession, subjectBuffer: IText override this.TryGetValue() = match tryGetDocument subjectBuffer, matchedField |> ValueOption.bind (tryGetFieldSpan this.Session) with | ValueSome document, ValueSome span -> - // 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 span) | _ -> ValueNone diff --git a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs index 83fe8f77fa3..9324c406c76 100644 --- a/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs +++ b/vsintegration/src/FSharp.Editor/Snippets/SnippetIndentation.fs @@ -6,18 +6,12 @@ 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 /// 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 @@ -36,7 +30,6 @@ module internal SnippetIndentation = /// `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. type Placement = /// Insert Snippet. The caret already positioned the opening line; the rest follow it. | AtCaret of column: int @@ -45,32 +38,66 @@ module internal SnippetIndentation = /// 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)) - - /// 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 - | FSharpTokenizerColorState.String - | FSharpTokenizerColorState.VerbatimString - | FSharpTokenizerColorState.TripleQuoteString -> true - | _ -> false + /// The public list of lexer states is missing some of the string ones, so the state is asked how it reads code. + let private isInsideString (tokenizer: FSharpSourceTokenizer) lexState = + match (tokenizer.CreateLineTokenizer "x").ScanToken lexState with + | Some token, _ -> token.ColorClass = FSharpTokenColorKind.String + | None, _ -> false + + let rec private leadingTokenAndEndState (tokenizer: FSharpLineTokenizer) lexState leadingToken = + match tokenizer.ScanToken lexState, leadingToken with + | (None, endState), _ -> struct (leadingToken, endState) + | (Some token, afterToken), ValueNone when token.ColorClass <> FSharpTokenColorKind.Default -> + leadingTokenAndEndState tokenizer afterToken (ValueSome token) + | (Some _, afterToken), _ -> leadingTokenAndEndState tokenizer afterToken leadingToken + + let private originOf selectedLines index = + match selectedLines with + | ValueSome(first, _) when index = first -> SelectedFirst + | ValueSome(first, last) when index > first && index <= last -> SelectedRest + | _ -> Template + + let private classifyLine (tokenizer: FSharpSourceTokenizer) tabSize origin lexState (text: string) = + let struct (leadingToken, endState) = + leadingTokenAndEndState (tokenizer.CreateLineTokenizer text) lexState ValueNone + + let kind = + match leadingToken with + | _ when String.IsNullOrWhiteSpace text -> Blank + | _ when isInsideString tokenizer lexState -> InsideString + | ValueSome token when token.ColorClass = FSharpTokenColorKind.PreprocessorKeyword -> RootLevelDirective + | _ -> origin + + { + Kind = kind + Indent = text |> Seq.takeWhile Char.IsWhiteSpace |> Seq.fold (advanceColumn tabSize) 0 + }, + endState + + /// `selectedLines` are the first and last index filled from `$selected$`. Those lines are lexed apart from the + /// snippet's own, so a directive or string one of them leaves open does not reach into the other. + let classify tabSize selectedLines (lines: string list) = + let tokenizer = FSharpSourceTokenizer([], None, None) + + lines + |> List.indexed + |> List.mapFold + (fun struct (template, selection) (index, text) -> + match originOf selectedLines index with + | Template -> + let line, template = classifyLine tokenizer tabSize Template template text + line, struct (template, selection) + | selected -> + let line, selection = classifyLine tokenizer tabSize selected selection text + line, struct (template, selection)) + struct (FSharpTokenizerLexState.Initial, FSharpTokenizerLexState.Initial) + |> fst /// How far each line has to move. Positive inserts, negative removes, zero leaves it alone. let deltas placement (lines: Line list) = diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs index 4e957dffbc2..0c86e2cbfe2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetCatalogTests.fs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace FSharp.Editor.Tests +module FSharp.Editor.Tests.SnippetCatalogTests open System open System.IO @@ -9,232 +9,149 @@ 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(41, 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 +open Microsoft.VisualStudio.FSharp.Editor - 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}" +open FSharp.Compiler.Diagnostics +open FSharp.Test + +type private Snippet = + { + Title: string + Shortcut: string + Types: string list + Literals: (string * string) list + Code: string + } + + member this.IsSurroundsWith = List.contains "SurroundsWith" this.Types + +let private xmlns = + XNamespace.Get "http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet" + +let private catalog = Path.Combine(AppContext.BaseDirectory, "Snippets", "1033") +let private indexPath = Path.Combine(catalog, "SnippetsIndex.xml") + +let private names = + Directory.GetFiles(Path.Combine(catalog, "FSharp"), "*.snippet") + |> Array.map Path.GetFileNameWithoutExtension + |> Array.sort + +let private pathOf name = + Path.Combine(catalog, "FSharp", $"%s{name}.snippet") + +let private load name = + let snippet = + XDocument.Load(pathOf name).Descendants(xmlns + "CodeSnippet") |> Seq.exactlyOne + + let header = snippet.Element(xmlns + "Header") + let body = snippet.Element(xmlns + "Snippet") + + { + Title = header.Element(xmlns + "Title").Value + Shortcut = header.Element(xmlns + "Shortcut").Value + Types = [ for element in header.Descendants(xmlns + "SnippetType") -> element.Value ] + Literals = + [ + for literal in body.Descendants(xmlns + "Literal") -> + literal.Element(xmlns + "ID").Value, literal.Element(xmlns + "Default").Value + ] + Code = body.Element(xmlns + "Code").Value + } + +let private mentionsSelected (text: string) = + text.IndexOf("$selected$", StringComparison.Ordinal) >= 0 + +/// `$end$` always sits in expression position, so `do ()` stands in for it. +let private expand snippet = + snippet.Literals + |> List.fold (fun (code: string) (id, dflt) -> code.Replace($"$%s{id}$", dflt)) snippet.Code + |> _.Replace("$selected$", "").Replace("$end$", "do ()").Replace("$$", "$") + +let private indented (code: string) = " " + code.Replace("\n", "\n ") + +/// A body is a fragment, so it only parses inside the right kind of host. +let private hosts = + [ + "whole file", id + "module level", (fun code -> $"module TestHost\n\n%s{code}\n") + "type body", (fun code -> $"module TestHost\n\ntype Host() =\n%s{indented code}\n") + "function body", (fun code -> $"module TestHost\n\nlet f () =\n%s{indented code}\n") + ] + +let private parseErrors source = + CompilerAssert.Parse(source, fileName = "Test.fs") + |> _.Diagnostics + |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error) + +let snippetNames: obj[][] = [| for name in names -> [| name |] |] + +[] +let ``The catalog ships the snippets the registration promises`` () = + Assert.Equal(41, names.Length) + Assert.True(File.Exists indexPath, $"missing {indexPath}") + +[] +let ``Shortcuts and titles are unique`` () = + let snippets = names |> Array.map load + + let duplicatesBy key = + snippets |> Seq.countBy key |> Seq.filter (fun (_, count) -> count > 1) + + Assert.Empty(duplicatesBy _.Shortcut) + Assert.Empty(duplicatesBy _.Title) + +[] +let ``Snippet is an Expansion under its own name, authored at column zero with spaces`` (name: string) = + let snippet = load name + // `pp_if` follows C#, which cannot name a file `#if`. + let shortcut = if name = "pp_if" then "#if" else name + + Assert.Contains("Expansion", snippet.Types) + Assert.Equal(shortcut, snippet.Shortcut) + Assert.Equal(snippet.Shortcut, snippet.Title) + Assert.DoesNotContain("\t", snippet.Code) + Assert.False(snippet.Code.StartsWith(" ", StringComparison.Ordinal), "body must start at column 0") + +[] +let ``Snippet literals are all declared and all used`` (name: string) = + let snippet = load name + + let referenced = + Regex.Matches(snippet.Code, @"\$([A-Za-z][A-Za-z0-9]*)\$") + |> Seq.cast + |> Seq.map _.Groups[1].Value + |> Seq.filter (fun id -> id <> "end" && id <> "selected") + |> Set.ofSeq + + Assert.Equal>(snippet.Literals |> List.map fst |> Set.ofList, referenced) + +[] +let ``Snippet marks the caret and a surround field the expansion client can read back`` (name: string) = + let snippet = load name + let layout = tryReadSelectedFieldLayout (pathOf name) + + Assert.Contains("$end$", snippet.Code) + Assert.Equal(snippet.IsSurroundsWith, mentionsSelected snippet.Code) + + if snippet.IsSurroundsWith then + let lines = snippet.Code.Replace("\r\n", "\n").Split '\n' + let fieldLine = lines |> Array.findIndex mentionsSelected + let field = lines[fieldLine] + + // The engine indents wrapped text by the field's column, so nothing but whitespace may precede it. + Assert.Equal(ValueSome(fieldLine, field.Length - field.TrimStart().Length), layout) + else + Assert.Equal(ValueNone, layout) + +[] +let ``Snippet expands to F# that parses`` (name: string) = + let code = expand (load name) + let errorsByHost = [ for host, wrap in hosts -> host, parseErrors (wrap code) ] + + if errorsByHost |> List.forall (snd >> Array.isEmpty >> not) then + let firstErrors = + errorsByHost + |> List.map (fun (host, errors) -> $"%s{host}: %s{errors[0].Message}") + |> String.concat "; " + + Assert.Fail($"%s{name} does not parse in any host: %s{firstErrors}\n---\n%s{code}") diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs index b5d182b2f4f..9dc59e0545d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetFunctionTests.fs @@ -1,161 +1,140 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace FSharp.Editor.Tests +module FSharp.Editor.Tests.SnippetFunctionTests open System open Xunit +open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks -open FSharp.Compiler.Text - open FSharp.Editor.Tests.Helpers -/// `ClassName()` and `GenerateMatchCases()` against a real document and its check results, the way the -/// expansion engine calls them. -module internal SnippetFunctionTestHelpers = - - let documentOf (source: string) = - RoslynTestHelpers.CreateSolution(source) |> RoslynTestHelpers.GetSingleDocument - - let containingTypeName (source: string) = - let position = source.IndexOf("Marked", StringComparison.Ordinal) - - tryGetContainingTypeName (documentOf source) position - |> CancellableTask.runSynchronouslyWithoutCancellation - - /// The rules generated for `match with`, one per element. - let matchRules (context: string) (expression: string) = - let source = $"{context}\nlet run () =\n match {expression} with\n | _ -> 0\n" +let private rangeOf (document: Document) (source: string) span = + RoslynHelpers.TextSpanToFSharpRange(document.FilePath, span, SourceText.From source) - let document = documentOf source - let text = SourceText.From source +/// The innermost type's name at `Marked`, or "" when it is not inside a type. +let private containingTypeName (source: string) = + let document = RoslynTestHelpers.GetFsDocument source + let marked = TextSpan(source.IndexOf("Marked", StringComparison.Ordinal), 0) - let positionOf offset = - let linePosition = text.Lines.GetLinePosition offset - Position.mkPos (linePosition.Line + 1) linePosition.Character + tryGetContainingTypeName document (rangeOf document source marked).Start + |> CancellableTask.runSynchronouslyWithoutCancellation + |> ValueOption.defaultValue "" - let start = - source.IndexOf($"match {expression} with", StringComparison.Ordinal) - + "match ".Length +/// The rules generated for the expression matched on, one per line, or "" when there are none. +let private matchRules (context: string) (expression: string) = + let prefix = $"{context}\nlet run () =\n match " + let source = $"{prefix}{expression} with\n | _ -> 0\n" + let document = RoslynTestHelpers.GetFsDocument source - let range = - Range.mkRange document.FilePath (positionOf start) (positionOf (start + expression.Length)) + tryGetMatchRulesAt document (rangeOf document source (TextSpan(prefix.Length, expression.Length))) + |> CancellableTask.runSynchronouslyWithoutCancellation + |> ValueOption.map _.Replace("\r\n", "\n") + |> ValueOption.defaultValue "" - tryGetMatchRulesAt document range - |> CancellableTask.runSynchronouslyWithoutCancellation - |> ValueOption.map (fun rules -> rules.Split([| '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries)) - -type SnippetFunctionTests() = - - static member typeNames: obj[][] = +let typeNames: obj[][] = + [| + [| "a type at the top level"; "type C() =\n member _.Marked = 0"; "C" |] [| - [| "a type at the top level"; "type C() =\n member _.Marked = 0"; "C" |] - [| - "a type in a module" - "module Outer =\n type C() =\n member _.Marked = 0" - "C" - |] - [| - "a type in nested modules" - "module A =\n module B =\n type C() =\n member _.Marked = 0" - "C" - |] - [| - "the type the position is in, of two" - "type First() =\n member _.Value = 0\n\ntype Second() =\n member _.Marked = 0" - "Second" - |] + "a type in a module" + "module Outer =\n type C() =\n member _.Marked = 0" + "C" |] - - static member matchCases: obj[][] = [| - [| - "a union in scope" - "type U = A | B\nlet value = U.B" - "value" - [| "| A -> ()"; "| B -> ()" |] - |] - [| - "a union with fields" - "type Shape = Circle of int | Square of int * int | Empty\nlet value = Empty" - "value" - [| "| Circle _ -> ()"; "| Square _ -> ()"; "| Empty -> ()" |] - |] - [| - "a RequireQualifiedAccess union in scope" - "[]\ntype U = A | B\nlet value = U.B" - "value" - [| "| U.A -> ()"; "| U.B -> ()" |] - |] - [| - "a RequireQualifiedAccess union in a module that is not open" - "module Outer =\n []\n type U = A | B\n\nlet value = Outer.U.B" - "value" - [| "| Outer.U.A -> ()"; "| Outer.U.B -> ()" |] - |] - [| - "a union in a module that is not open" - "module Outer =\n type U = A | B\n\nlet value = Outer.U.B" - "value" - [| "| Outer.U.A -> ()"; "| Outer.U.B -> ()" |] - |] - [| - "a union in a module that is open" - "module Outer =\n type U = A | B\n\nopen Outer\nlet value = U.B" - "value" - [| "| A -> ()"; "| B -> ()" |] - |] - [| - "an enum in a module that is not open" - "module Outer =\n type E = | X = 1 | Y = 2\n\nlet value = Outer.E.X" - "value" - [| "| Outer.E.X -> ()"; "| Outer.E.Y -> ()"; "| _ -> ()" |] - |] - [| - "a call, whose result is what is matched" - "type Input = X | Y\ntype Output = A | B\nlet make (_: Input) = B\nlet value = Y" - "make value" - [| "| A -> ()"; "| B -> ()" |] - |] + "a type in nested modules" + "module A =\n module B =\n type C() =\n member _.Marked = 0" + "C" |] - - static member noMatchCases: obj[][] = [| - [| "a function value"; "type U = A | B\nlet make (_: int) = B"; "make" |] - [| "a type that is neither a union nor an enum"; ""; "1" |] + "the type the position is in, of two" + "type First() =\n member _.Value = 0\n\ntype Second() =\n member _.Marked = 0" + "Second" |] + [| + "the type the position is in, of a recursive group" + "type First() =\n member _.Marked = 0\n\nand Second = | A" + "First" + |] + [| "no type outside a type"; "let Marked = 1"; "" |] + |] - [] - [] - member _.``ClassName names the type the snippet lands in, without its enclosing modules`` - (_name: string, source: string, expected: string) - = - Assert.Equal( - expected, - SnippetFunctionTestHelpers.containingTypeName source - |> ValueOption.defaultValue "no type" - ) - - [] - member _.``ClassName has no answer outside a type``() = - Assert.True((SnippetFunctionTestHelpers.containingTypeName "let Marked = 1").IsNone) - - [] - [] - member _.``GenerateMatchCases spells each case the way it resolves at the match`` - (_name: string, context: string, expression: string, expected: string[]) - = - match SnippetFunctionTestHelpers.matchRules context expression with - | ValueSome rules -> Assert.Equal(List.ofArray expected, List.ofArray rules) - | ValueNone -> failwith "no match rules were generated" - - [] - [] - member _.``GenerateMatchCases leaves the default alone when the expression is not a union or enum`` - (_name: string, context: string, expression: string) - = - Assert.True((SnippetFunctionTestHelpers.matchRules context expression).IsNone) +let matchCases: obj[][] = + [| + [| + "a union in scope" + "type U = A | B\nlet value = U.B" + "value" + "| A -> ()\n| B -> ()" + |] + [| + "a union with fields" + "type Shape = Circle of int | Square of int * int | Empty\nlet value = Empty" + "value" + "| Circle _ -> ()\n| Square _ -> ()\n| Empty -> ()" + |] + [| + "a union case that needs backticks" + "type U = | ``A B`` | C\nlet value = C" + "value" + "| ``A B`` -> ()\n| C -> ()" + |] + [| + "a RequireQualifiedAccess union in scope" + "[]\ntype U = A | B\nlet value = U.B" + "value" + "| U.A -> ()\n| U.B -> ()" + |] + [| + "a RequireQualifiedAccess union in a module that is not open" + "module Outer =\n []\n type U = A | B\n\nlet value = Outer.U.B" + "value" + "| Outer.U.A -> ()\n| Outer.U.B -> ()" + |] + [| + "a union in a module that is not open" + "module Outer =\n type U = A | B\n\nlet value = Outer.U.B" + "value" + "| Outer.U.A -> ()\n| Outer.U.B -> ()" + |] + [| + "a union in a module that is open" + "module Outer =\n type U = A | B\n\nopen Outer\nlet value = U.B" + "value" + "| A -> ()\n| B -> ()" + |] + [| + "an enum in a module that is not open" + "module Outer =\n type E = | X = 1 | Y = 2\n\nlet value = Outer.E.X" + "value" + "| Outer.E.X -> ()\n| Outer.E.Y -> ()\n| _ -> ()" + |] + [| + "a call, whose result is what is matched" + "type Input = X | Y\ntype Output = A | B\nlet make (_: Input) = B\nlet value = Y" + "make value" + "| A -> ()\n| B -> ()" + |] + [| + "a function value gives nothing" + "type U = A | B\nlet make (_: int) = B" + "make" + "" + |] + [| "a type that is neither a union nor an enum gives nothing"; ""; "1"; "" |] + |] + +[] +let ``ClassName names the type the snippet lands in, without its enclosing modules, and nothing outside a type`` + (_name: string, source: string, expected: string) + = + Assert.Equal(expected, containingTypeName source) + +[] +let ``GenerateMatchCases spells each case the way it resolves at the match, and nothing for other types`` + (_name: string, context: string, expression: string, expected: string) + = + Assert.Equal(expected, matchRules context expression) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs index 9462f1c9b6a..2763680997a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Snippets/SnippetIndentationTests.fs @@ -1,146 +1,132 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace FSharp.Editor.Tests +module FSharp.Editor.Tests.SnippetIndentationTests 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 -/// 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 private insideString indent = - { Kind = InsideString; 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 ``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 ] - - 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) - - [] - 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) +/// Each line as its kind, the indentation the engine left it at, and the indentation it should end up at. +let private scenarios = + [ + "Surround With for over two lines nests both under the loop", + AroundSelection(12, 4), + [ Template, 0, 12; SelectedFirst, 16, 16; SelectedRest, 12, 16 ] + + "Surround With async keeps the wrapper at the code's column", + AroundSelection(20, 4), + [ Template, 0, 20; SelectedFirst, 24, 24; Template, 0, 20 ] + + "Surround With a directive pair pins it to column zero and does not nest", + AroundSelection(20, 0), + [ RootLevelDirective, 0, 0; SelectedFirst, 20, 20; RootLevelDirective, 0, 0 ] + + "Insert Snippet leaves the opening line where the caret put it", AtCaret 8, [ Template, 8, 8; Template, 4, 12; Template, 0, 8 ] + + "Insert Snippet after a tab nests the body by the tab's width", + AtCaret(advanceColumn 4 0 '\t'), + [ Template, 4, 4; Template, 4, 8; Template, 0, 4 ] + + "Insert Snippet still pins a directive to column zero", + AtCaret 8, + [ RootLevelDirective, 8, 0; Template, 4, 12; RootLevelDirective, 0, 0 ] + + "A blank line is left alone", AroundSelection(20, 4), [ Template, 0, 20; Blank, 0, 0; Template, 0, 20 ] + + "A selection keeps its own internal shape", AroundSelection(12, 4), [ Template, 0, 12; SelectedFirst, 16, 16; SelectedRest, 16, 20 ] + + "A line inside a string carried over from the selection is left alone", + AroundSelection(20, 4), + [ Template, 0, 20; SelectedFirst, 0, 0; InsideString, 0, 0 ] + ] + +let scenarioNames: obj[][] = [| for name, _, _ in scenarios -> [| name |] |] + +let private kindsOf selectedLines lines = + classify 4 selectedLines lines |> List.map _.Kind + +[] +let ``Each line moves to where its kind and the placement put it`` (name: string) = + let _, placement, lines = + scenarios |> List.find (fun (scenario, _, _) -> scenario = name) + + let engine = + lines |> List.map (fun (kind, indent, _) -> { Kind = kind; Indent = indent }) + + let expected = lines |> List.map (fun (_, _, indent) -> indent) + + let actual = + deltas placement engine + |> List.map2 (fun (line: Line) delta -> line.Indent + delta) engine + + Assert.Equal(expected, actual) + +[] +[] +[] +[] +[] +let ``A tab runs on to the next tab stop and any other character takes one column`` + (tabSize: int, column: int, character: char, expected: int) + = + Assert.Equal(expected, advanceColumn tabSize column character) + +[] +let ``Indentation is measured in visual columns`` () = + Assert.Equal([ 6 ], classify 4 ValueNone [ "\t x" ] |> List.map _.Indent) + +[] +let ``A directive is recognized wherever the engine left it`` () = + let lines, kinds = + [ + "#if DEBUG", RootLevelDirective + " code", Template + " #elif TRACE", RootLevelDirective + " #else", RootLevelDirective + " code", Template + "#endif", RootLevelDirective + "#nowarn 0040", RootLevelDirective + " #warnon 0040", RootLevelDirective + ] + |> List.unzip + + Assert.Equal(kinds, kindsOf ValueNone lines) + +[] +[] +[ ()">] +[] +let ``Code is not mistaken for a directive`` (line: string) = + Assert.Equal([ Template ], kindsOf ValueNone [ line ]) + +[] +let ``A blank line stays blank inside a branch the lexer skips`` () = + Assert.Equal([ RootLevelDirective; Blank; RootLevelDirective ], kindsOf ValueNone [ "#if A"; " "; "#endif" ]) + +[] +[] +[] +[] +[ ignore")>] +[] +[] +let ``A selected line that continues a string is left alone, even inside a directive wrapper`` (opening: string, continuation: string) = + Assert.Equal( + [ RootLevelDirective; SelectedFirst; InsideString; RootLevelDirective ], + kindsOf (ValueSome(1, 2)) [ "#if DEBUG"; opening; continuation; "#endif" ] + ) + +[] +[ printfn \"%s\"")>] +[] +let ``A selected line that only starts with a string, or sits in an interpolation hole, is code`` (first: string, rest: string) = + Assert.Equal([ SelectedFirst; SelectedRest ], kindsOf (ValueSome(0, 1)) [ first; rest ]) + +[] +[] +[] +let ``A directive the selection leaves unbalanced does not move the wrapper's own`` (selected: string) = + let kinds = + kindsOf (ValueSome(1, 2)) [ "#if DEBUG"; " let b = 3"; selected; "#endif" ] + + Assert.Equal(RootLevelDirective, List.last kinds)