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