Skip to content
8 changes: 8 additions & 0 deletions .claude/rules/FSharp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
paths:
- "src/**/*.{fs,fsi,fsx}"
- "vsintegration/src/**/*.{fs,fsi}"
- "tests/**/*.{fs,fsi,fsx}"
---

@../../.github/instructions/FSharp.instructions.md
95 changes: 95 additions & 0 deletions .github/instructions/FSharp.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
applyTo:
- "src/**/*.{fs,fsi,fsx}"
- "vsintegration/src/**/*.{fs,fsi}"
- "tests/**/*.{fs,fsi,fsx}"
---

# Writing F#

Which constructs to reach for. Code shape and comments are NoBloat's; naming and the abbreviation glossary belong to `docs/coding-standards.md` and `DEVGUIDE.md`.

These rules govern code you write or touch. Do not sweep the codebase to apply them – `CONTRIBUTING.md`: *"DO NOT submit large code formatting changes without discussing with the team first."*

## Tools

When the IDE's F# semantic tools are unavailable, use the `F#` MCP server (`.mcp.json`, FsLangMCP) for navigation, symbol discovery, diagnostics and cross-project usage search. Prefer its semantic tools over plain text search for F#-specific work.

## Strings

- Prefer interpolated strings over `sprintf` and `String.Format`. Use `$"""…"""` when the text itself contains quotes.
- Format specifiers are valid in interpolated strings and help type inference: `$"count %d{n}"`.
- `nameof` over a string literal that names a value, member or type.
- An explicit `StringComparison` on every `Equals`, `StartsWith`, `EndsWith`, `IndexOf` and `Compare`, and an explicit comparer on every `HashSet<string>` and `Dictionary<string, _>`. `Ordinal` by default; culture-sensitive comparison is a decision, never a default. Reach for `OrdinalIgnoreCase` only where the thing compared really is case-insensitive – never for identifiers, which are case-sensitive.
- `Contains` has no `StringComparison` overload on `netstandard2.0`. Spell it `text.IndexOf(value, StringComparison.Ordinal) >= 0`, as `XmlDocInheritance.fs` does.

## Values and types

- `voption` – `ValueSome`/`ValueNone` – over `option`; it is this compiler's option type. Fields, members, parameters and values shared between threads included: none of them is a reason to pick `option`. Exception: when an API hands you `'T option` and has no `voption` counterpart, unwrap with `Option.defaultValue`/`Option.defaultWith` directly – do not insert `ValueOption.ofOption` just to switch modules.
- The mirror case, an API that *takes* `'T option` (an optional argument `?caret = …`, a field typed `IDisposable option`): stay in `ValueOption` through the whole chain and convert once, last – `x |> ValueOption.bind _.Position |> ValueOption.toOption`, never `x |> ValueOption.toOption |> Option.bind _.Position`.
- `vsintegration` has `voption`-returning counterparts of the FSharp.Core collection functions, suffixed `V`, in `FSharp.Editor/Common/Extensions.fs`: `Seq.tryHeadV`/`tryFindV`/`tryFindIndexV`/`tryPickV`/`chooseV`, `Array.tryHeadV`/`tryFindV`/`tryPickV`/`chooseV`, `List.tryFindV`, `ImmutableArray.tryHeadV`. Reach for those rather than the `option`-returning original. The module is `[<AutoOpen>]` and compiles before the rest of `FSharp.Editor`, so a file in the `Microsoft.VisualStudio.FSharp.Editor` namespace needs no `open` for them. `src/Compiler` has no equivalents.
- `struct ('T1 * 'T2)` tuples and `[<Struct>]` types on allocation-sensitive paths.
- Anonymous struct records (`struct {| … |}`) over bare tuples for multi-value returns of internal helpers. Public FCS surface is governed by `.fsi` files and compatibility – do not change it for style.
- The compiler generates `IsCaseName` properties (`IsDefault`, `IsCustom`) for DU cases – use them for a specific-case check instead of a full `match`.
- Deconstruct `KeyValuePair` with the `KeyValue` active pattern: `for KeyValue(k, v) in map do …`.
- `[<InlineIfLambda>]` on `inline` higher-order helpers whose lambda argument must not become a closure.

## Lambdas and collections

- Prefer the `_.Property` shorthand in pipeline position: `tys |> List.map _.Type`. Complex expressions (`fun x -> x.Name = name`, `fun x -> x.A, x.B`) cannot use it; a member chain ending in a method call is not complex: `_.LineChanged.Subscribe(handler)`. The shorthand needs its input type known, so pipe the value in first – `position |> ValueOption.map _.Line`, not `ValueOption.map _.Line position` (FS0072). Never add a space – `_.MethodCall ()` breaks parsing. Unrelated to the `member _.Foo` self-identifier.
- Eta-reduce: `Seq.map (fun x -> someFunction x)` must become `Seq.map someFunction` – unless the lambda is load-bearing. A method group names its captured receiver with a synthesized name that varies by optimization setting, so a closure something reflects over needs the explicit lambda (the Type Provider SDK's `tcImports` capture in `CompilerImports.fs`).
- Prefer a single traversal – one `fold`, loop, or comprehension – to a chain of transformations: it allocates nothing per element, where a chain allocates at every stage.
- When the chain reads better than one pass, route it through `Seq` and materialize once at the end – a `List`/`Array` chain allocates a whole intermediate collection per stage, a `Seq` chain only an enumerator.
- Concatenate with `[ yield! xs; yield! ys ]` / `seq { yield! xs; yield! ys }` rather than `@` or `Seq.append` – `@` forces both sides to lists and is O(n).
- `Seq.cast<Target>` is for genuinely untyped input – a non-generic `IEnumerable` such as `MatchCollection` or `XmlNodeList` (`ServiceParsedInputOps.fs`, `fsihelp.fs`). It unboxes per item and is unchecked: `[ 1; 2; 3 ] |> Seq.cast<string>` compiles and throws at run time. Retype an already-typed sequence through an upcast the compiler checks instead.

## Async and exceptions

- `src/Compiler` targets `netstandard2.0`: prefer `async { }` and the repo's `cancellable { }` (`src/Compiler/Utilities/Cancellable.fs`). `task { }` appears only in `Service/FSharpProjectSnapshot.fs` and `Service/FSharpWorkspaceQuery.fs` – avoid it elsewhere in new core code.
- `vsintegration` runs on the VS threading model where `task { }` is at home. When an override must return non-generic `Task`, annotate explicitly – `override _.M(…) : Task = task { … }` – never cast through pipelines.
- Thread cancellation through; see `ExpertReview.instructions.md`.
- `reraise ()` does not compile inside a `task`/`async` CE (FS0413). There, rethrow with `ExceptionDispatchInfo.Capture(ex).Throw()`; outside CEs plain `reraise ()` is correct.

## Nullness

Enabled in `src/Compiler`, `src/FSharp.Build`, `src/FSharp.Compiler.LanguageServer`. There:

- Declare non-nullable; check for `null` at entry points. Trust C#/F# annotations – no null checks where the type system says a value cannot be null.
- Prefer `match x with | null -> … | x -> …` over `isNull` – the match narrows the type, `isNull` does not.
- Use `withNull` to hint nullability instead of boxing (`isNull (box f)`).
- Before suppressing a nullness warning (3261, 3262, …), escalate in order: `nonNull value` (runtime assert, fail fast) → `Unchecked.nonNull value` (null provably impossible upstream) → inline `#nowarn`/`#warnon` pair, centralised in one interop helper rather than scattered across call sites.

## Warning suppression

Inline `#nowarn "NN"` / `#warnon "NN"` pairs around the smallest possible scope – they are valid anywhere in an `.fs` file, not only at the top. File-level suppression is a last resort.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ This file also applies to src/FSharp.Build, which targets F# 9. Nested #warnon produces FS3350 there. Restrict this rule to F# 10 projects or keep an F# 9-compatible form.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea440d8: added a note that scoped #nowarn/#warnon is an F# 10 feature (LanguageFeature.ScopedNowarn, gated at languageVersion100), and that src/FSharp.Build (pinned to LangVersion 9) needs suppression at the top of the file instead — that's FS3350 there otherwise.


Scoped `#nowarn`/`#warnon` is an F# 10 feature (`LanguageFeature.ScopedNowarn`). `src/FSharp.Build` is pinned to `LangVersion 9`, where the same pair is FS3350 – there, suppression stays at the top of the file.

## Classes (mostly `vsintegration`)

- Initializer syntax over post-construction property assignment: `MyType(ctorArg, MutableProp1 = v1, MutableProp2 = (5 |> string))` – settable properties by name after positional arguments, computed values in parentheses.
- Extension members consumable from C#: `[<AutoOpen; Extension>]` module, `[<Extension; CompiledName "…">]` on each member.
- Prefer a root module (`module Ns.FeatureExtensions`) over `namespace` + a static holder type for extension files; name it after the single target type plus `Extensions`, or after the feature when there are several targets.
- XML doc comments that use markup (`<see/>`, `<c/>`) need their text wrapped in `<summary>`.

## Opens

Sort into blank-line-separated groups, alphabetically within each: `System.*` → `Microsoft.*` → `Internal.Utilities.*` → `FSharp.Compiler.*` (see the top of `src/Compiler/Service/TransparentCompiler.fs`). `open type` last within its group; type and module aliases at the very end.

## Do not mistake for conventions

This codebase implements every F# feature, so finding one here is no evidence that it is used here. These have no foothold – introducing them is a new pattern, not a continuation:

- `while!` and `and!` – no uses at all; the matches are the parser and the checker implementing them.
- `[<TailCall>]` – a handful of deliberate assertions on specific recursive functions, not a habit.

## The language version is not uniform

`FSharp.Profiles.props` sets `LangVersion=preview`, but not under `Configuration=Proto` and not when `BUILDING_USING_DOTNET=true`. The compiler bootstraps, so a feature this repository is *adding* cannot be used in its own source until it ships in the SDK compiler named by `global.json` – otherwise the Proto stage fails.

- `src/FSharp.Build` is pinned to `LangVersion 9`; it can load in Visual Studio against an older FSharp.Core.
- `src/FSharp.Core` leaves nullness off and is bound by `docs/fsharp-core-notes.md`.

## Tests

The ComponentTests DSL and its pipeline are covered by `ComponentTests.instructions.md`. Name tests with backticked spaces: ``let ``Issue 12345 - brief description`` () = …``.
Loading