Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S) - #20521
Open
xperiandri wants to merge 3 commits into
Open
Code snippets for F# in Visual Studio (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)#20521xperiandri wants to merge 3 commits into
Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)#20521xperiandri wants to merge 3 commits into
Conversation
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>
Contributor
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xperiandri
marked this pull request as ready for review
September 11, 2026 09:51
Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)
Contributor
Author
Contributor
|
🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Design-Time, Affects-Test-Tooling
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




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 CodeSnippets Manager has no F# entry, and there is no
.snippetfile anywhere in this repository.This adds the expansion path and a C#-parity catalog on top of it:
Ctrl+K,Ctrl+X), Surround With (Ctrl+K,Ctrl+S), and Tab expansion of asnippet shortcut.
F#▸Visual F#.ClassName()fills the enclosing type name intoctorandequals,GenerateMatchCases()generates the cases of the union or enummatchis given.Fixes #1498
Design notes
No Roslyn reuse, because there is no seam. Everything Roslyn has for snippets —
AbstractSnippetCommandHandler,SnippetExpansionClient,ISnippetExpansionLanguageHelper,AbstractSnippetFunction— isinternalunderMicrosoft.VisualStudio.LanguageServices.Implementation.Snippets, and no*ExternalAccess*assembly exposes a snippet or expansion type. So F# writes its own
IVsExpansionClient, the wayAutomaticCompletion/BraceCompletionSessionProvider.fsalready 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 typesin
Microsoft.VisualStudio.Text.UI, so one MEFICommandHandler<_>part covers all six commands —no
IOleCommandTarget/VSStd2KCmdIDplumbing. It is[<Order(After = PredefinedCompletionNames.CompletionCommandHandler)>]so Tab still commits an open completion listfirst, exactly as C# orders it. The expansion itself goes through
IVsExpansionManager/IVsExpansion/IVsExpansionSession, which is the only pathCtrl+K,Ctrl+Xhas — Roslyn's newer
ISnippetProvidermodel explicitly does not cover it(
docs/ide/specs/semantic_snippets.md, Non-Goals). No newPackageReference: the COM surface is inMicrosoft.VisualStudio.TextManager.Interop.8.0, already on the compile closure.Registration is a pkgdef.
ProvideLanguageCodeExpansionAttributedoes not expose thePackagevalue that
DisplayNameresolves against, so it cannot produce a correct Code Snippets Managerentry 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 andVSPackage.resxID 100 (already localized in all 13xlf), and registers1033outright ratherthan
%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.fsonly matches the previous line), soFormatSpancomputes thecolumns 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 thingleft in the client. Every case in
SnippetIndentationTests.fsis an insertion that came out wrongat 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/#endifand the scoped#nowarn/#warnonpair read at the left margin whatever they wrap, so unlikeasync {they do nottake 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:
tsInsertPosis the rangeInsertNamedExpansionreplaces, so passing it the selection deletesthe code a
SurroundsWithsnippet was meant to wrap. The engine reads the selection off theIVsTextViewit was handed inInvokeInsertionUIto fill$selected$— which is why the legacyExpansionProvider.OnItemChosenin this repo passesGetCaretPosand nothing else.GetFieldSpan "selected"does not answer for that special literal, so the substituted range isderived 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
Expansionunless marked,S= alsoSurroundsWith.module,class,record,du,interface,struct,enum,exn,attribute,mainmember,abstract,override,prop,propfull,ctor,equals,iface,dispose,extifS,ifeS,match,matcho,matchvo,matchr,matcht,forS,forrS,whileS,tryS,tryfS,use,lockS,#ifS (pp_if.snippet, as C# names it),nowarnSasyncS,taskS,seqS,pfnDropped from the C# set with no F# analogue:
checked,unchecked,unsafe,invoke,iterator,iterindex,indexer,sim/svm(covered bymain),mbox,cw(covered bypfn),~,pp_region(F# has no#region), andnamespace/foreach/do/else, which are one keyword eachin 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 indentationis applied at insertion time, so the file is the truth. (C#'s files carry ad-hoc hard tabs because
the formatter normalizes them afterwards.)
$end$. That is what removes the need to read the snippet XMLback out of the live session, and with it the need for Roslyn's re-declared
IVsExpansionSessionInternalthat exists solely to dodge an access violation inGetSnippetNodeon second invocation.
$selected$sits alone on its own line in everySurroundsWithsnippet, which is what makes thecolumn arithmetic exact.
match*variants are plain literals rather thanGenerateMatchCases()calls: their casesets 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 theSurroundsWithsnippets and alone on its line, notabs, 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 whichsnippet 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.fsis a column recorded from thoseruns. A
FSharp.Editor.IntegrationTestscase 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.Nextmust be called on the UIthread — its IDL declares
rgeltasout, so a background-thread marshal nulls the buffer),localized snippet folders under
%LCID%,<Imports>/<References>support,SimpleTypeName(), andsnippets for test methods.
Checklist
docs/release-notes/.VisualStudio/18.vNext.md)🤖 Generated with Claude Code