Skip to content

Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S) - #20521

Open
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:feature/vs-code-snippets
Open

Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)#20521
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:feature/vs-code-snippets

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Description

F# is the only first-class Visual Studio language with no code-snippet support: Ctrl+K,Ctrl+X
("Insert Snippet") and Ctrl+K,Ctrl+S ("Surround With") do nothing in .fs/.fsx, the Code
Snippets Manager has no F# entry, and there is no .snippet file anywhere in this repository.

This adds the expansion path and a C#-parity catalog on top of it:

  • Insert Snippet (Ctrl+K,Ctrl+X), Surround With (Ctrl+K,Ctrl+S), and Tab expansion of a
    snippet shortcut.
  • 40 built-in snippets, listed under Tools ▸ Code Snippets Manager as F#Visual F#.
  • Two snippet functions: ClassName() fills the enclosing type name into ctor and equals,
    GenerateMatchCases() generates the cases of the union or enum match is given.

Fixes #1498

Design notes

No Roslyn reuse, because there is no seam. Everything Roslyn has for snippets —
AbstractSnippetCommandHandler, SnippetExpansionClient, ISnippetExpansionLanguageHelper,
AbstractSnippetFunction — is internal under
Microsoft.VisualStudio.LanguageServices.Implementation.Snippets, and no *ExternalAccess*
assembly exposes a snippet or expansion type. So F# writes its own IVsExpansionClient, the way
AutomaticCompletion/BraceCompletionSessionProvider.fs already writes its own brace completion.
Roslyn is the design reference, not a base class.

Modern command handlers, legacy expansion engine. The command args
(InsertSnippetCommandArgs, SurroundWithCommandArgs, the four key args) are public platform types
in Microsoft.VisualStudio.Text.UI, so one MEF ICommandHandler<_> part covers all six commands —
no IOleCommandTarget/VSStd2KCmdID plumbing. It is [<Order(After = PredefinedCompletionNames.CompletionCommandHandler)>] so Tab still commits an open completion list
first, exactly as C# orders it. The expansion itself goes through
IVsExpansionManager/IVsExpansion/IVsExpansionSession, which is the only path Ctrl+K,Ctrl+X
has — Roslyn's newer ISnippetProvider model explicitly does not cover it
(docs/ide/specs/semantic_snippets.md, Non-Goals). No new PackageReference: the COM surface is in
Microsoft.VisualStudio.TextManager.Interop.8.0, already on the compile closure.

Registration is a pkgdef. ProvideLanguageCodeExpansionAttribute does not expose the Package
value that DisplayName resolves against, so it cannot produce a correct Code Snippets Manager
entry on its own; C#, VB, XAML, XML and TypeScript all register by hand. The block goes into the
existing vsintegration/Vsix/RegisterFsharpPackage.pkgdef, reuses the F# language service GUID and
VSPackage.resx ID 100 (already localized in all 13 xlf), and registers 1033 outright rather
than %LCID% — registering both would enumerate every snippet twice on an English VS.

Indentation is ours, not the engine's. The expansion engine inserts snippet text 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$ keeping whatever indentation it had in the buffer. C#
survives that because Roslyn's formatter reflows the result afterwards; F# has no formatter
(Formatting/IndentationService.fs only matches the previous line), so FormatSpan computes the
columns itself. That policy is the part where the mistakes live, so it is a separate module free of
editor types — Snippets/SnippetIndentation.fs, ~50 lines — with the buffer edit as the only thing
left in the client. Every case in SnippetIndentationTests.fs is an insertion that came out wrong
at some point, recorded as the columns the engine left behind and the columns the result should
have. 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 unlike async { they do not
take the column of the code they cover — and the code keeps the column it had.

Two things fell out of getting that right and are worth flagging for review:

  • tsInsertPos is the range InsertNamedExpansion replaces, so passing it the selection deletes
    the code a SurroundsWith snippet was meant to wrap. The engine reads the selection off the
    IVsTextView it was handed in InvokeInsertionUI to fill $selected$ — which is why the legacy
    ExpansionProvider.OnItemChosen in this repo passes GetCaretPos and nothing else.
  • GetFieldSpan "selected" does not answer for that special literal, so the substituted range is
    derived instead: the template says which of its lines holds the field and at what column, and the
    command handler counts the lines the selection covered before the insertion replaces it.

The catalog

Expansion unless marked, S = also SurroundsWith.

  • Declarations: module, class, record, du, interface, struct, enum, exn,
    attribute, main
  • Members: member, abstract, override, prop, propfull, ctor, equals, iface,
    dispose, ext
  • Control flow: if S, ife S, match, matcho, matchvo, matchr, matcht, for S,
    forr S, while S, try S, tryf S, use, lock S, #if S (pp_if.snippet, as C# names it),
    nowarn S
  • Computation expressions and misc: async S, task S, seq S, pfn

Dropped from the C# set with no F# analogue: checked, unchecked, unsafe, invoke, iterator,
iterindex, indexer, sim/svm (covered by main), mbox, cw (covered by pfn), ~,
pp_region (F# has no #region), and namespace/foreach/do/else, which are one keyword each
in F#.

Deliberate constraints on the files, all enforced by the catalog test:

  • <Code> bodies are authored at column 0 with 4-space relative indentation — absolute indentation
    is applied at insertion time, so the file is the truth. (C#'s files carry ad-hoc hard tabs because
    the formatter normalizes them afterwards.)
  • Every snippet carries an explicit $end$. That is what removes the need to read the snippet XML
    back out of the live session, and with it the need for Roslyn's re-declared
    IVsExpansionSessionInternal that exists solely to dodge an access violation in GetSnippetNode
    on second invocation.
  • $selected$ sits alone on its own line in every SurroundsWith snippet, which is what makes the
    column arithmetic exact.
  • The four match* variants are plain literals rather than GenerateMatchCases() calls: their case
    sets are known, so they cost nothing at insertion time and work even when the function falls back.

Tests

vsintegration/tests/FSharp.Editor.Tests/Snippets/:

  • SnippetCatalogTests.fs — 242 cases over the shipped files: schema, unique shortcuts and titles,
    shortcut matches the filename, every $literal$ declared and every declaration used, explicit
    $end$, $selected$ present exactly in the SurroundsWith snippets and alone on its line, no
    tabs, the $selected$ layout the expansion client reads back out of each file at insertion time,
    and — the one that keeps "insert a snippet, the file still compiles" honest — every body
    expanded at its defaults parses in one of four hosts (whole file, module level, type body,
    function body).
  • SnippetIndentationTests.fs — 14 cases over the column arithmetic described above, and over which
    snippet lines count as directives.

The snippet files are copied next to the test binary the way the VSIX lays them out beside
FSharp.Editor.dll, so the tests run against the shipping layout.

Beyond the unit tests, this code was deployed to the experimental hive and exercised by hand — Insert Snippet, Surround With over code-level and directive wrappers, and the Code Snippets
Manager registration. Every case in SnippetIndentationTests.fs is a column recorded from those
runs. A FSharp.Editor.IntegrationTests case driving the keybindings would be a natural follow-up.

Not in scope

In rough priority order, in rough priority order: snippet shortcuts as IntelliSense completion items (C# does
this; note for whoever picks it up that IVsExpansionEnumeration.Next must be called on the UI
thread — its IDL declares rgelt as out, so a background-thread marshal nulls the buffer),
localized snippet folders under %LCID%, <Imports>/<References> support, SimpleTypeName(), and
snippets for test methods.

Checklist

  • Test cases added
  • Performance benchmarks added in case of performance changes — not applicable: no existing path changes; the two snippet functions run only when a snippet that declares them is inserted
  • Release notes entry updated (docs/release-notes/.VisualStudio/18.vNext.md)

🤖 Generated with Claude Code

xperiandri and others added 2 commits September 11, 2026 09:41
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xperiandri
xperiandri marked this pull request as ready for review September 11, 2026 09:51
@xperiandri
xperiandri requested a review from a team as a code owner September 11, 2026 09:51
@xperiandri xperiandri changed the title Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S) Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S) Sep 11, 2026
@xperiandri

Copy link
Copy Markdown
Contributor Author
image image image image

@github-actions github-actions Bot added the ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure label Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Design-Time, Affects-Test-Tooling
Affects-Build-Infra: Changes VSIX targets and packaged build content.
Affects-Design-Time: Adds editor services executing inside Visual Studio.
Affects-Test-Tooling: Changes test content-copy and compilation configuration.

Generated by PR Tooling Safety Check · gpt56 1.3M ·

@github-actions github-actions Bot added ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Test-Tooling Tooling check: PR touches test framework infrastructure labels Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Test-Tooling Tooling check: PR touches test framework infrastructure

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

Suggestion: support F# Snippets

1 participant