Add F# code snippets (Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S) - #20521
xperiandri wants to merge 4 commits into
Conversation
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
|
Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)
|
🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Design-Time, Affects-Test-Tooling
|
|
/azp run fsharp-ci |
|
Commenter does not have sufficient privileges for PR 20521 in repo dotnet/fsharp |
T-Gro
left a comment
There was a problem hiding this comment.
🤖 🕵️ AI review — verify independently.
| if case.HasFields then | ||
| $"| %s{case.Name} _ -> ()" | ||
| else | ||
| $"| %s{case.Name} -> ()") |
There was a problem hiding this comment.
🤖 🕵️ Wrong branch for [<RequireQualifiedAccess>] unions: generated A/B patterns bind variables instead of testing cases, so U.B takes the first arm.
[<RequireQualifiedAccess>]
type U = A | B
let value = U.B
// Insert match for value; fill the generated bodies with 0 and 1.
let run () =
match value with
| A -> 0
| B -> 1
// run () returns 0, not 1; the patterns need U.A and U.B.| | Template, AtCaret column -> if index = 0 then 0 else column | ||
| | Template, AroundSelection(column, _) -> column | ||
| | SelectedFirst, _ -> 0 | ||
| | SelectedRest, AroundSelection(_, fieldIndent) -> fieldIndent |
There was a problem hiding this comment.
🤖 🕵️ Surround With changes multiline string values: wrapping this assignment in if true changes "a\nb" to "a\n b".
let mutable captured = ""
// Select the following two lines and surround with if true:
captured <- """a
b"""| | _ -> ValueSome declaration) | ||
| ValueNone | ||
|
|
||
| return innermost |> ValueOption.map _.LogicalName |
There was a problem hiding this comment.
🤖 🕵️ Inserting equals inside a nested module generates :? Outer.C, which fails with FS0039 because Outer is not in scope yet.
module Outer =
type C() =
member _.Value = 0
// Insert equals here: ClassName() produces Outer.C instead of C.| /// The match rules covering the union or enum at `position`, or ValueNone for anything else. | ||
| let tryGetMatchRules (document: Document) position = | ||
| cancellableTask { | ||
| let! lexerSymbol = document.TryFindFSharpLexerSymbolAsync(position, SymbolLookupKind.Greedy, false, false, userOpName) |
There was a problem hiding this comment.
🤖 🕵️ Matching a function application generates cases from its final argument instead of its result, producing FS0001 rather than valid match arms.
type Input = X | Y
type Output = A | B
let make (_: Input) = B
let value = Y
// Insert match with expression make value; generated arms:
match make value with
| X -> () // FS0001: expected Output, got Input
| Y -> ()| </Literal> | ||
| </Declarations> | ||
| <Code Language="FSharp"><![CDATA[lock $lockObject$ (fun () -> | ||
| $selected$$end$)]]></Code> |
There was a problem hiding this comment.
🤖 🕵️ Surround With puts ) inside a selected trailing // comment, leaving lock with an unmatched (. Keep the closing parenthesis on its own line.
let gate = obj()
lock gate (fun () ->
printfn "working" // retained user comment
)
T-Gro
left a comment
There was a problem hiding this comment.
🤖🕵️ If this fixes an issue or implements an RFC/suggestion, link it (Fixes #... when applicable). Otherwise, give a short management-level summary in simplified technical English: what user scenario improves and what this achieves.
Please apply this PR-description guidance. Remove the implementation inventory already visible in Files, but keep necessary scope, compatibility, and dependency caveats.
Ctrl+K,Ctrl+X / Ctrl+K,Ctrl+S)|
Rewrote per the guidance — description is now |
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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A RequireQualifiedAccess union rejects a bare case pattern (`A`, not `U.A`) - the
generated pattern binds a fresh variable instead of testing the case, so it takes the
first arm regardless of the actual value. matchRulesFor now qualifies with the
entity's DisplayName when the union carries the attribute, the same way it already
does for enums.
ClassName() used the navigation item's LogicalName, which is qualified by every
enclosing module ("Outer.C") - it does not resolve from a constructor sitting inside
C's own scope. Strips to the name after the last '.'.
GenerateMatchCases() resolved the lexer symbol nearest the field's end position,
which is whatever identifier happens to sit there - for `f x`, that is `x`, not the
call `f x`. Reads the type the checker captured for the field's whole span instead
(TryGetCapturedType), so it matches the expression's own result type regardless of
its shape.
Surround With reindented every non-first selected line uniformly, including a line
that is itself inside a multi-line string continued from an earlier selected line -
inserting indentation there changes the string's value, not just its position.
classifyLines now threads the lexer's color state across the span and classifies
such a line as InsideString, left untouched like a blank line.
lock.snippet closed the lambda on the same line as $selected$$end$, so a selection
whose last line ends in a trailing // comment swallowed the closing paren into the
comment, leaving the call unclosed. Moved onto its own line, matching how the other
wrapping snippets already close.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
75ab317 to
50be8a5
Compare




Fixes #1498
Adds Visual Studio code-snippet support for F#:
Ctrl+K,Ctrl+Xinserts a snippet,Ctrl+K,Ctrl+Ssurrounds a selection, and a snippet shortcut expands on Tab. Ships a built-in catalog matching C#'s (declarations, members, control flow, computation expressions);ctor/equalsfill in the enclosing type name andmatchgenerates the cases of the union or enum it is given.Verified by hand in the experimental hive in addition to the added tests.
Not in scope: snippet shortcuts as an IntelliSense completion item (as C# offers), localized snippet folders,
<Imports>/<References>support, and snippets for test methods.Checklist
🤖 Generated with Claude Code