From 2dda5fbbea6564a609b0ecf6dd11b88f4fcfdcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=CE=BBstor?= Date: Fri, 5 Jun 2026 17:24:32 +0200 Subject: [PATCH 1/3] dotnet-fsharp: Adding Basic F# Skills --- .agents/plugins/marketplace.json | 5 + .claude-plugin/marketplace.json | 5 + .cursor-plugin/marketplace.json | 5 + .github/plugin/marketplace.json | 5 + README.md | 1 + eng/known-domains.txt | 6 + plugins/dotnet-fsharp/plugin.json | 6 + .../SKILL.md | 115 ++++++++++ .../skills/convert-csharp-to-fsharp/SKILL.md | 83 +++++++ .../design-fsharp-for-dotnet-interop/SKILL.md | 135 +++++++++++ .../format-fsharp-with-fantomas/SKILL.md | 105 +++++++++ .../skills/fsharp-active-patterns/SKILL.md | 114 ++++++++++ .../skills/fsharp-async-and-tasks/SKILL.md | 131 +++++++++++ .../skills/fsharp-domain-modeling/SKILL.md | 134 +++++++++++ .../skills/fsharp-error-handling/SKILL.md | 148 ++++++++++++ .../fsharp-project-organization/SKILL.md | 135 +++++++++++ .../skills/fsharp-scripts/SKILL.md | 158 +++++++++++++ .../skills/fsharp-type-providers/SKILL.md | 90 ++++++++ .../skills/fsharp-units-of-measure/SKILL.md | 112 ++++++++++ .../skills/testing-fsharp/SKILL.md | 122 ++++++++++ .../skills/writing-idiomatic-fsharp/SKILL.md | 121 ++++++++++ .../references/csharpism-rewrites.md | 211 ++++++++++++++++++ .../eval.yaml | 25 +++ .../convert-csharp-to-fsharp/eval.yaml | 47 ++++ .../eval.yaml | 28 +++ .../format-fsharp-with-fantomas/eval.yaml | 23 ++ .../fsharp-active-patterns/eval.yaml | 28 +++ .../fsharp-async-and-tasks/eval.yaml | 34 +++ .../fsharp-domain-modeling/eval.yaml | 33 +++ .../fsharp-error-handling/eval.yaml | 30 +++ .../fsharp-project-organization/eval.yaml | 24 ++ tests/dotnet-fsharp/fsharp-scripts/eval.yaml | 35 +++ .../fsharp-type-providers/eval.yaml | 26 +++ .../fsharp-units-of-measure/eval.yaml | 27 +++ tests/dotnet-fsharp/testing-fsharp/eval.yaml | 22 ++ .../writing-idiomatic-fsharp/eval.yaml | 51 +++++ 36 files changed, 2380 insertions(+) create mode 100644 plugins/dotnet-fsharp/plugin.json create mode 100644 plugins/dotnet-fsharp/skills/authoring-computation-expressions/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/convert-csharp-to-fsharp/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/design-fsharp-for-dotnet-interop/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/format-fsharp-with-fantomas/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-active-patterns/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-async-and-tasks/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-domain-modeling/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-project-organization/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-scripts/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-type-providers/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/fsharp-units-of-measure/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/testing-fsharp/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md create mode 100644 plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md create mode 100644 tests/dotnet-fsharp/authoring-computation-expressions/eval.yaml create mode 100644 tests/dotnet-fsharp/convert-csharp-to-fsharp/eval.yaml create mode 100644 tests/dotnet-fsharp/design-fsharp-for-dotnet-interop/eval.yaml create mode 100644 tests/dotnet-fsharp/format-fsharp-with-fantomas/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-active-patterns/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-async-and-tasks/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-domain-modeling/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-error-handling/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-project-organization/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-scripts/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-type-providers/eval.yaml create mode 100644 tests/dotnet-fsharp/fsharp-units-of-measure/eval.yaml create mode 100644 tests/dotnet-fsharp/testing-fsharp/eval.yaml create mode 100644 tests/dotnet-fsharp/writing-idiomatic-fsharp/eval.yaml diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index e69e40b798..e2cd10d41d 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -61,6 +61,11 @@ "name": "dotnet-aspnet", "source": "./plugins/dotnet-aspnet", "description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns." + }, + { + "name": "dotnet-fsharp", + "source": "./plugins/dotnet-fsharp", + "description": "Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop." } ] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fabb569089..e04efa0b98 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -68,6 +68,11 @@ "name": "dotnet11", "source": "./plugins/dotnet11", "description": "Skills for new .NET 11 APIs and language features." + }, + { + "name": "dotnet-fsharp", + "source": "./plugins/dotnet-fsharp", + "description": "Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop." } ] } \ No newline at end of file diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index ee70692b82..f10bc81d50 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -71,6 +71,11 @@ "name": "dotnet11", "source": "./plugins/dotnet11", "description": "Skills for new .NET 11 APIs and language features." + }, + { + "name": "dotnet-fsharp", + "source": "./plugins/dotnet-fsharp", + "description": "Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop." } ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index fabb569089..e04efa0b98 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -68,6 +68,11 @@ "name": "dotnet11", "source": "./plugins/dotnet11", "description": "Skills for new .NET 11 APIs and language features." + }, + { + "name": "dotnet-fsharp", + "source": "./plugins/dotnet-fsharp", + "description": "Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop." } ] } \ No newline at end of file diff --git a/README.md b/README.md index 0648dd8e85..5206fefa34 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This repository contains the .NET team's curated set of core skills and custom a | [dotnet-aspnet](plugins/dotnet-aspnet/) | ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns. | | [dotnet-blazor](plugins/dotnet-blazor/) | Skills for Blazor development: component authoring, interactivity, and web application patterns. | | [dotnet11](plugins/dotnet11/) | Skills for new .NET 11 APIs and language features. | +| [dotnet-fsharp](plugins/dotnet-fsharp/) | Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop. | ## Installation diff --git a/eng/known-domains.txt b/eng/known-domains.txt index fd3b5061c9..112bc67d58 100644 --- a/eng/known-domains.txt +++ b/eng/known-domains.txt @@ -62,6 +62,12 @@ github.com/username # Test smell research testsmells.org +# F# ecosystem +fsprojects.github.io +fscheck.github.io +github.com/fsprojects/fantomas +github.com/haf/expecto + # Community github.com/Youssef1313/Combinatorial.MSTest ollama.com diff --git a/plugins/dotnet-fsharp/plugin.json b/plugins/dotnet-fsharp/plugin.json new file mode 100644 index 0000000000..d2fae284b0 --- /dev/null +++ b/plugins/dotnet-fsharp/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "dotnet-fsharp", + "version": "0.1.0", + "description": "Idiomatic F# coding skills: functional-first design, domain modeling, error handling, scripting, and .NET interop.", + "skills": ["./skills/"] +} diff --git a/plugins/dotnet-fsharp/skills/authoring-computation-expressions/SKILL.md b/plugins/dotnet-fsharp/skills/authoring-computation-expressions/SKILL.md new file mode 100644 index 0000000000..9d1d5698d4 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/authoring-computation-expressions/SKILL.md @@ -0,0 +1,115 @@ +--- +name: authoring-computation-expressions +description: "Author custom F# computation expression builders (the result {}, option {}, async {} style of block). Use when you want a let!/return DSL for a wrapper type such as Result or Option, to remove repetitive bind/map chains, or to build a small workflow builder. Covers the builder class, the core members (Bind, Return, ReturnFrom, Zero, Combine, Delay, For, While, Using, TryWith), and a minimal result/option builder. Do not use for merely consuming existing computation expressions, or where a plain pipeline of bind/map is already clear." +license: MIT +--- + +# Authoring F# Computation Expressions + +## Purpose + +A computation expression (CE) is a builder object that gives a type a `let!`/`return` block +syntax. Authoring one lets you replace repetitive `bind`/`map` chains with readable sequential +code for your own wrapper types. + +## When to Use + +- You repeatedly chain `Result.bind` / `Option.bind` and want `let!`/`return` instead +- You have a custom wrapper/effect type that would benefit from block syntax +- You are building a small internal DSL (parsers, builders, pipelines) + +## When Not to Use + +- You only need to **use** an existing CE (`async`, `task`, `result`) - just use it +- A single `bind`/`map` pipeline is already clear; a CE adds indirection +- A library already provides the builder (e.g. FsToolkit.ErrorHandling's `result`/`validation`) + +## How a CE works + +`builder { ... }` desugars to method calls on a builder instance: + +| Syntax | Builder member | +|--------|----------------| +| `let! x = e in rest` | `Bind(e, fun x -> rest)` | +| `return x` | `Return(x)` | +| `return! e` | `ReturnFrom(e)` | +| (empty / `if` with no else) | `Zero()` | +| two statements in sequence | `Combine(a, b)` | +| delayed evaluation | `Delay(fun () -> ...)` | +| `for x in xs do ...` | `For(xs, body)` | +| `while cond do ...` | `While(guard, body)` | +| `use x = e in ...` | `Using(e, body)` | +| `try ... with` | `TryWith(body, handler)` | + +You only implement the members your block actually uses. + +## A minimal result builder + +```fsharp +type ResultBuilder() = + member _.Bind(result, f) = Result.bind f result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + member _.Zero() = Ok () + +let result = ResultBuilder() + +// usage +let compute a b = + result { + let! x = if a > 0 then Ok a else Error "a must be positive" + let! y = if b > 0 then Ok b else Error "b must be positive" + return x + y + } +``` + +`let!` chains via `Bind`; the first `Error` short-circuits the rest of the block. + +## A minimal option builder + +```fsharp +type OptionBuilder() = + member _.Bind(opt, f) = Option.bind f opt + member _.Return(value) = Some value + member _.ReturnFrom(opt) = opt + +let option = OptionBuilder() +``` + +## Adding more members + +- Add `Zero` to allow `if cond then return! x` with no `else`. +- Add `Combine` + `Delay` to allow multiple statements / early constructs. +- Add `For`/`While` only if the block needs loops. +- Add `Using`/`TryWith`/`TryFinally` for resource and exception handling inside the block. + +Implement incrementally: the compiler error names the missing member when a block needs one. + +## Workflow + +1. Identify the wrapper type and the repetitive `bind`/`map` chain. +2. Write a builder class with `Bind` and `Return` (and `ReturnFrom`/`Zero` as needed). +3. Instantiate it as a lowercase value (`let result = ResultBuilder()`). +4. Rewrite the chain as a `builder { ... }` block. +5. Add further members only when a compiler error asks for one. +6. Verify with `dotnet fsi`. + +## Validation + +- [ ] Builder implements at least `Bind` and `Return` with correct types +- [ ] The CE block compiles and behaves like the underlying `bind`/`map` chain +- [ ] Short-circuiting / sequencing matches the wrapper's semantics +- [ ] No CE authored where an existing one (or a plain pipeline) would do + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Re-implementing `result`/`validation` that a library already ships | Use FsToolkit.ErrorHandling instead | +| Wrong `Bind` signature | `Bind: M<'a> * ('a -> M<'b>) -> M<'b>` for the wrapper `M` | +| Block needs `Zero`/`Combine` but they are missing | Add the member the compiler error names | +| Authoring a CE for a one-off two-step chain | Just use `bind`/`map` directly | + +## More info + +- Computation expressions: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions diff --git a/plugins/dotnet-fsharp/skills/convert-csharp-to-fsharp/SKILL.md b/plugins/dotnet-fsharp/skills/convert-csharp-to-fsharp/SKILL.md new file mode 100644 index 0000000000..158eca3bd4 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/convert-csharp-to-fsharp/SKILL.md @@ -0,0 +1,83 @@ +--- +name: convert-csharp-to-fsharp +description: "Translate C# code into idiomatic F#, not a literal line-by-line port. Use when porting a C# class, method, or file to F#, or when an F# translation needs to be made idiomatic. Produces F# that uses records/DUs, Option over null, Result over exceptions, pattern matching, and pipelines. Composes the writing-idiomatic-fsharp, fsharp-domain-modeling, and fsharp-error-handling skills, plus design-fsharp-for-dotnet-interop when the result must stay C#-consumable. Do not use for fresh F# design with no C# source (use writing-idiomatic-fsharp / fsharp-domain-modeling)." +license: MIT +--- + +# Converting C# to F# + +## Purpose + +Port C# to F# that an F# developer would actually write - leveraging records, discriminated +unions, `Option`, `Result`, pattern matching, and pipelines - rather than transliterating C# +syntax into `.fs`. + +## When to Use + +- Porting a C# class, method, or file to F# +- Cleaning up an F# translation that still reads like C# +- Migrating a component to F# while keeping (or dropping) C# consumability + +## When Not to Use + +- Designing fresh F# with no C# source - use `writing-idiomatic-fsharp` and + `fsharp-domain-modeling` directly + +## How C# constructs map to idiomatic F# + +| C# | Idiomatic F# | +|----|--------------| +| POCO / DTO class with get/set | `record` (immutable; `with` for updates) | +| `enum` | discriminated union (or enum if interop needs it) | +| class hierarchy / `abstract` + subclasses for variants | discriminated union + `match` | +| `null` / nullable reference | `Option` (`Some`/`None`) | +| `throw` / `try-catch` for expected failures | `Result` + `result { }` (see `fsharp-error-handling`) | +| `if/else if` chains, `switch` | `match` | +| `for`/`foreach` building a collection | `List`/`Seq` `map`/`filter`/`fold` | +| `interface` with methods | `interface` (kept) or a record/DU of functions where simpler | +| `static` helper class | a `module` of functions | +| LINQ (`Where`/`Select`/`Aggregate`) | `List.filter`/`List.map`/`List.fold` pipelines | +| `async`/`await`, `Task` | `task { }` / `async { }` (see `fsharp-async-and-tasks`) | + +## Workflow + +1. **Understand the C#** - identify data types, control flow, error handling, and async. +2. **Model the data first** (`fsharp-domain-modeling`): DTOs to records, enums/variants to DUs, + nullable to `Option`; add smart constructors for validated values. +3. **Translate behavior** (`writing-idiomatic-fsharp`): `switch`/`if` to `match`, loops to + collection functions, nested calls to pipelines, LINQ to `List`/`Seq` functions. +4. **Convert error handling** (`fsharp-error-handling`): expected exceptions to `Result`; wrap + genuinely throwing .NET calls at the boundary. +5. **Convert async** (`fsharp-async-and-tasks`): `Task`/`await` to `task { }`/`async { }`; no + `.Result` blocking. +6. **Preserve interop if required** (`design-fsharp-for-dotnet-interop`): if C# must still + consume the result, keep the public surface C#-friendly. +7. **Mind file order**: in a project, place definitions before use in `.fsproj` + (`fsharp-project-organization`). +8. **Verify**: build/run and, where practical, port or run the existing tests to confirm + behavior is preserved. + +## Validation + +- [ ] DTO classes became immutable records; variant hierarchies/enums became DUs +- [ ] `null` replaced with `Option`; expected exceptions replaced with `Result` +- [ ] `switch`/`if` chains became `match`; loops became collection functions; LINQ became + pipelines +- [ ] `async`/`await` became `task`/`async` with no blocking `.Result` +- [ ] Behavior preserved (tests pass or output matches the C# original) +- [ ] If C#-consumable: public surface still follows the interop rules + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Line-by-line transliteration | Re-model the data and control flow idiomatically first | +| Keeping `null` and `try/catch` as-is | Map to `Option`/`Result` | +| Porting mutable classes verbatim | Prefer records/DUs; isolate mutation if genuinely needed | +| Ignoring `.fsproj` file order | Order definitions before use; entry point last | +| Breaking C# callers during a partial migration | Apply `design-fsharp-for-dotnet-interop` to the public surface | + +## More info + +- F# style guide: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/ +- F# for C#/OO developers: https://learn.microsoft.com/en-us/dotnet/fsharp/ diff --git a/plugins/dotnet-fsharp/skills/design-fsharp-for-dotnet-interop/SKILL.md b/plugins/dotnet-fsharp/skills/design-fsharp-for-dotnet-interop/SKILL.md new file mode 100644 index 0000000000..811d48b4f6 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/design-fsharp-for-dotnet-interop/SKILL.md @@ -0,0 +1,135 @@ +--- +name: design-fsharp-for-dotnet-interop +description: "Shape an F# public API so it is natural to consume from C# and other .NET languages (a vanilla .NET library). Use when an F# library, assembly, or layer is consumed by C#/VB, or when reviewing an F# public surface for interop friction. Covers hiding discriminated unions, exposing seq/IDictionary instead of F# list/Map, Func/Action instead of F# functions, Task instead of Async, the TryGetValue pattern instead of option, CompiledName, CLIEvent, and null checks at the boundary. Do not use for F#-to-F# internal code (use writing-idiomatic-fsharp / fsharp-domain-modeling)." +license: MIT +--- + +# Designing F# for .NET Interop + +## Purpose + +F#-idiomatic types (`Async`, F# functions, `list`, `Map`, `option`, discriminated unions) +appear awkward or alien to C# consumers. This skill shapes the **public** surface of an F# +component so it follows .NET Library Design Guidelines and feels native from C#/VB - while the +implementation stays idiomatic F# internally. + +## When to Use + +- An F# library/assembly is consumed by C# or other .NET languages +- Reviewing an F# public API for interop friction +- Publishing an F# NuGet package for general .NET use + +## When Not to Use + +- F#-to-F# internal code - keep it idiomatic (`writing-idiomatic-fsharp`, + `fsharp-domain-modeling`) + +## The interop rules + +Apply these at the **public boundary** only; implement internally however is idiomatic. + +| F#-idiomatic (internal) | C#-friendly (public surface) | +|-------------------------|------------------------------| +| `namespace` + modules with `let` functions | `namespace` + `[]` static-member types | +| F# `list`, `Map`, `Set` | `seq` (`IEnumerable`), `IDictionary` | +| F# function `int -> int` | `System.Func` / `System.Action<...>` | +| `Async` | `Task` (via `Async.StartAsTask`), with a `CancellationToken` overload | +| returns `option` | `bool` + `out` param (`TryGetValue` pattern) | +| takes `option` | method overloads or optional arguments | +| public discriminated union | hidden (`private`/signature file) + members / active patterns | +| curried params `f a b` | tupled params `Method(a, b)` | +| F# `Event` | `DelegateEvent` + `[]` | + +### Static members instead of module functions + +```fsharp +namespace Fabrikam + +[] +type Utilities = + static member Add(x, y) = x + y + static member Add(x, y, z) = x + y + z +``` + +Static types allow overloading and future evolution; modules compile to a shape C# cannot use +as cleanly. + +### Hide DUs; expose members or active patterns + +```fsharp +type Shape = + private + | Circle of float + | Rect of float * float + + static member CreateCircle r = Circle r + member this.Area = + match this with + | Circle r -> System.Math.PI * r * r + | Rect (w, h) -> w * h +``` + +### Async to Task at the boundary + +```fsharp +type Service() = + let computeAsync x = async { return x + 1 } // idiomatic internally + member _.ComputeAsync(x, ct) = + Async.StartAsTask(computeAsync x, cancellationToken = ct) +``` + +### CompiledName for .NET-friendly names + +```fsharp +type Vector(x: float, y: float) = + member _.X = x + [] + static member create x y = Vector(x, y) +``` + +### Check for null at the boundary + +C# callers pass `null` freely; validate before it reaches F# code. + +```fsharp +let checkNonNull name (arg: obj) = + match arg with + | null -> nullArg name + | _ -> () +``` + +## Workflow + +1. Separate the public surface from the implementation (an `Api` type/module is a good seam). +2. Replace F# collection types with `seq`/`IDictionary` on signatures. +3. Replace F# function parameters with `Func`/`Action`. +4. Convert `Async` returns to `Task`; add a `CancellationToken` overload. +5. Replace `option` returns with `TryGetValue`; `option` params with overloads. +6. Hide DUs; expose members/active patterns/factory methods. Use tupled, not curried, params. +7. Add `null` checks at the boundary. Optionally add an `.fsi` to lock the surface. +8. Verify the shape - ideally compile a small C# caller, or inspect with the object browser. + +## Validation + +- [ ] Public signatures use `seq`/`IDictionary`, not F# `list`/`Map`/`Set` +- [ ] Public delegates are `Func`/`Action`, not F# function types +- [ ] Async members return `Task` with a cancellation overload +- [ ] No public `option` returns (TryGetValue) or `option` params (overloads) +- [ ] Public DUs are hidden behind members/active patterns +- [ ] Public methods use tupled parameters +- [ ] `null` is checked at the boundary + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Exposing module `let` functions to C# | Use a static-member type (`[]`) | +| Returning F# `list`/`Map` | Return `seq`/`IDictionary` | +| Public `Async` | Return `Task` via `Async.StartAsTask` | +| Public DU consumed by C# | Hide it; expose members or `CreateX` factories | +| Curried public method | Use tupled `Method(a, b)` | + +## More info + +- Component design guidelines: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/component-design-guidelines +- .NET library design guidelines: https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/ diff --git a/plugins/dotnet-fsharp/skills/format-fsharp-with-fantomas/SKILL.md b/plugins/dotnet-fsharp/skills/format-fsharp-with-fantomas/SKILL.md new file mode 100644 index 0000000000..3275a20b7f --- /dev/null +++ b/plugins/dotnet-fsharp/skills/format-fsharp-with-fantomas/SKILL.md @@ -0,0 +1,105 @@ +--- +name: format-fsharp-with-fantomas +description: "Format F# code with Fantomas, the standard F# code formatter, and configure its style via .editorconfig. Use when formatting F# files, enforcing consistent F# style in a repo, wiring up a format check in CI, or resolving disagreements about F# layout. Covers installing/running the Fantomas dotnet tool, formatting and check modes, and key .editorconfig (fsharp_*) settings. Do not use for code logic/idiom changes (use writing-idiomatic-fsharp) - Fantomas only changes layout." +license: MIT +--- + +# Formatting F# with Fantomas + +## Purpose + +Fantomas is the de facto F# code formatter. It applies the F# style guide automatically and +ends layout debates - one consistent style, enforced by a tool, configurable through +`.editorconfig`. + +## When to Use + +- Formatting one or more F# files to a consistent style +- Enforcing F# formatting across a repository +- Adding a format check to CI +- Settling layout disagreements objectively + +## When Not to Use + +- Changing logic, idiom, or structure - Fantomas only reformats; use + `writing-idiomatic-fsharp` for idiom + +## Install and run + +Fantomas is a .NET tool. Use a local tool manifest so the version is pinned per repo. + +```bash +dotnet new tool-manifest # once per repo, if no .config/dotnet-tools.json +dotnet tool install fantomas # adds it to the manifest +``` + +Format in place: + +```bash +dotnet fantomas src/ # format a directory recursively +dotnet fantomas File.fs # format a single file +``` + +Check mode (no writes; non-zero exit if any file would change) - ideal for CI: + +```bash +dotnet fantomas --check src/ +``` + +## Configure style via .editorconfig + +Fantomas reads `fsharp_*` settings from `.editorconfig`. Place a `[*.fs]` (and `[*.fsx]`) +section at the repo root. + +```ini +[*.{fs,fsx,fsi}] +indent_size = 4 +max_line_length = 120 +fsharp_space_before_uppercase_invocation = false +fsharp_multiline_bracket_style = aligned +fsharp_keep_max_number_of_blank_lines = 1 +``` + +Common settings: + +| Setting | Effect | +|---------|--------| +| `max_line_length` | Wrap threshold (default 120) | +| `fsharp_multiline_bracket_style` | `cramped` / `aligned` / `stroustrup` layout for records & lists | +| `fsharp_keep_max_number_of_blank_lines` | Collapse runs of blank lines | +| `fsharp_space_before_uppercase_invocation` | Space before `(` on PascalCase calls | + +Keep configuration minimal; the defaults already follow the style guide. + +## Workflow + +1. Ensure a tool manifest exists and Fantomas is installed (`dotnet tool restore` if it is + already in the manifest). +2. Run `dotnet fantomas ` to format. +3. Add (or adjust) a `[*.{fs,fsx,fsi}]` section in `.editorconfig` only for deliberate + deviations from defaults. +4. Add `dotnet fantomas --check` to CI to keep the tree formatted. +5. Confirm the project still builds after formatting (`dotnet build`). + +## Validation + +- [ ] Fantomas is pinned in the tool manifest (`.config/dotnet-tools.json`) +- [ ] `dotnet fantomas ` formats without error +- [ ] `dotnet fantomas --check` passes on the formatted tree +- [ ] Any custom style lives in `.editorconfig`, not ad hoc +- [ ] The project still builds after formatting + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Globally installed Fantomas with a drifting version | Use a pinned local tool manifest | +| Hand-formatting to fight the formatter | Configure `.editorconfig`, then let Fantomas win | +| Expecting Fantomas to fix idiom | It only reformats; use `writing-idiomatic-fsharp` | +| `--check` not in CI | Add it so unformatted code is caught on PRs | + +## More info + +- F# style guide: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/ +- Fantomas docs: https://fsprojects.github.io/fantomas-docs/ +- Fantomas repo: https://github.com/fsprojects/fantomas diff --git a/plugins/dotnet-fsharp/skills/fsharp-active-patterns/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-active-patterns/SKILL.md new file mode 100644 index 0000000000..fd2eb7dafe --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-active-patterns/SKILL.md @@ -0,0 +1,114 @@ +--- +name: fsharp-active-patterns +description: "Use and author F# active patterns to make pattern matching expressive over data that is not a plain discriminated union. Use when classifying or destructuring values (parsing, matching on .NET types, ranges, regex captures) leads to nested if/match, or when you want named, reusable match cases. Covers total active patterns (|A|B|), partial patterns (|A|_|), parameterized patterns, and when to prefer them over plain match. Do not use for data already well modeled as a DU (just match it directly)." +license: MIT +--- + +# F# Active Patterns + +## Purpose + +Active patterns let you pattern-match over values whose shape is not already a discriminated +union - converting `int`, `string`, .NET objects, or computed classifications into named, +matchable cases. They turn nested conditionals into readable `match` expressions. + +## When to Use + +- Classifying a value into named buckets (even/odd, ranges, categories) +- Parsing: turning a `string` into a structured result inside a `match` +- Matching over .NET types you do not own +- Reusing the same classification logic across several `match` expressions + +## When Not to Use + +- The data is already a DU - match it directly, no active pattern needed +- A single inline `if` is clearer than introducing a named pattern + +## The three forms + +### 1. Total (complete) active pattern - covers all inputs + +```fsharp +let (|Even|Odd|) n = if n % 2 = 0 then Even else Odd + +let describe n = + match n with + | Even -> "even" + | Odd -> "odd" +``` + +A total pattern partitions the input into a fixed set of cases. The match is exhaustive. + +### 2. Partial active pattern - may not match (returns Option) + +Use when the value only sometimes fits the case. The name ends with `|_|`. + +```fsharp +let (|Int|_|) (s: string) = + match System.Int32.TryParse s with + | true, v -> Some v + | _ -> None + +let parse s = + match s with + | Int v -> sprintf "number %d" v + | _ -> "not a number" +``` + +### 3. Parameterized active pattern - takes extra arguments + +```fsharp +let (|DivisibleBy|_|) divisor n = + if n % divisor = 0 then Some () else None + +let fizzbuzz n = + match n with + | DivisibleBy 15 -> "FizzBuzz" + | DivisibleBy 3 -> "Fizz" + | DivisibleBy 5 -> "Buzz" + | _ -> string n +``` + +### Multiple captures with one partial pattern + +```fsharp +open System.Text.RegularExpressions + +let (|Regex|_|) pattern input = + let m = Regex.Match(input, pattern) + if m.Success then Some [ for g in m.Groups -> g.Value ] + else None + +let parseDate s = + match s with + | Regex @"(\d{4})-(\d{2})-(\d{2})" [ _; y; mo; d ] -> Some (y, mo, d) + | _ -> None +``` + +## Workflow + +1. Spot the nested `if`/`match` or repeated classification logic. +2. Choose the form: total (always one of N cases) or partial (`|_|`, may not match). +3. Add parameters if the test needs configuration. +4. Replace the conditional with a `match` over the new pattern(s). +5. Verify with `dotnet fsi`. + +## Validation + +- [ ] Nested conditionals replaced by a `match` over active patterns +- [ ] Partial patterns end in `|_|` and return `Option` +- [ ] Total patterns enumerate all cases and keep the match exhaustive +- [ ] The code compiles and matches as expected (`dotnet fsi`) + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Active pattern for data already a DU | Match the DU directly | +| Total pattern that cannot actually cover all inputs | Make it a partial pattern (`|_|`) returning `Option` | +| Heavy work inside a frequently matched pattern | Active patterns run on each match; keep them cheap or memoize | +| Too many tiny patterns | Reserve them for genuinely reused or clarifying classifications | + +## More info + +- Active patterns: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/active-patterns diff --git a/plugins/dotnet-fsharp/skills/fsharp-async-and-tasks/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-async-and-tasks/SKILL.md new file mode 100644 index 0000000000..15ec28d0a6 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-async-and-tasks/SKILL.md @@ -0,0 +1,131 @@ +--- +name: fsharp-async-and-tasks +description: "Write correct asynchronous F# with async {} and task {}, and bridge to .NET Task-based APIs. Use when writing async F#, choosing between async {} and task {}, fixing blocking calls (.Result/.Wait()) or deadlocks, propagating cancellation, running work in parallel, or interoperating with Task-returning .NET libraries. Covers async vs task semantics, Async.AwaitTask/Async.StartAsTask, Async.Parallel, and cancellation tokens. Do not use for shaping an async public API for C# consumers (use design-fsharp-for-dotnet-interop)." +license: MIT +--- + +# F# Async and Tasks + +## Purpose + +Write asynchronous F# that does not block threads or deadlock, and that interoperates cleanly +with the .NET `Task` world. + +## When to Use + +- Writing asynchronous F# (I/O, HTTP, database) +- Deciding between `async { }` and `task { }` +- Removing `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` blocking calls +- Running asynchronous work in parallel +- Calling `Task`-returning .NET APIs from F# and vice versa +- Threading cancellation tokens through async code + +## When Not to Use + +- Designing an async API surface for C# consumers - use `design-fsharp-for-dotnet-interop` + +## async {} vs task {} + +| | `async { }` | `task { }` | +|---|-------------|------------| +| Type | `Async<'T>` (a cold, composable description) | `Task<'T>` (hot, starts running) | +| Starts when | started explicitly (`Async.RunSynchronously`, `Async.Start`, `Async.StartAsTask`) | immediately on creation | +| Cancellation | implicit ambient `CancellationToken` | pass the token explicitly | +| Best for | composable F# pipelines, parallelism, ret/ cancellation as values | direct interop with `Task` .NET APIs, simplest call-through | + +Rule of thumb: prefer `task { }` when the surrounding code is `Task`-centric or interop-heavy; +prefer `async { }` when you compose, retry, or parallelize many operations and want them as +first-class values. + +## Never block on async + +```fsharp +// WRONG - blocks the thread, can deadlock +let content = httpClient.GetStringAsync(url).Result +``` + +```fsharp +// task: await it +let fetch url = + task { + let! content = httpClient.GetStringAsync url + return content + } +``` + +```fsharp +// async: await a Task with Async.AwaitTask +let fetch url = + async { + let! content = httpClient.GetStringAsync url |> Async.AwaitTask + return content + } +``` + +## Bridging async and Task + +```fsharp +// Task -> Async +let a = someTask |> Async.AwaitTask + +// Async -> Task (e.g. to hand to a Task-based API) +let t = someAsync |> Async.StartAsTask + +// run an Async to a value at a boundary (top-level only) +let value = someAsync |> Async.RunSynchronously +``` + +## Parallelism + +```fsharp +let fetchAll urls = + urls + |> List.map fetchAsync // url list -> Async list + |> Async.Parallel // -> Async +``` + +For `Task`, use `System.Threading.Tasks.Task.WhenAll`. + +## Cancellation + +`async { }` picks up the ambient cancellation token automatically: + +```fsharp +let work = async { ... } +Async.RunSynchronously(work, cancellationToken = token) +``` + +For `task { }`, accept a `CancellationToken` parameter and pass it to the calls you make. + +## Workflow + +1. Pick `async { }` or `task { }` based on interop vs composition. +2. Replace every blocking `.Result`/`.Wait()` with `let!` (awaiting via `Async.AwaitTask` in + `async`). +3. For many independent operations, use `Async.Parallel` / `Task.WhenAll`. +4. Thread cancellation: ambient for `async`, explicit token for `task`. +5. Only convert to a synchronous value (`Async.RunSynchronously`) at the outermost boundary. +6. Verify with `dotnet fsi`. + +## Validation + +- [ ] No `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` inside async code +- [ ] `async`/`task` choice matches the surrounding code (interop vs composition) +- [ ] Independent operations run with `Async.Parallel` / `Task.WhenAll`, not sequentially +- [ ] Cancellation is propagated (ambient for async, explicit token for task) +- [ ] Code compiles and runs (`dotnet fsi`) + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| `.Result` to "get the value" | `let!` inside `async`/`task`; never block | +| Expecting `async { }` to start on its own | `Async<'T>` is cold; start it (`StartAsTask`/`RunSynchronously`) | +| `Async.RunSynchronously` deep in a call chain | Only at the top-level boundary | +| Sequential `let!`s for independent work | Use `Async.Parallel` / `Task.WhenAll` | +| Dropping the cancellation token in `task { }` | Accept and forward a `CancellationToken` | + +## More info + +- Async programming: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/async-expressions +- Task expressions: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/task-expressions diff --git a/plugins/dotnet-fsharp/skills/fsharp-domain-modeling/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-domain-modeling/SKILL.md new file mode 100644 index 0000000000..6535fd0d83 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-domain-modeling/SKILL.md @@ -0,0 +1,134 @@ +--- +name: fsharp-domain-modeling +description: "Model domains in F# with the type system so illegal states are unrepresentable. Use when designing F# types, replacing primitive obsession (raw strings/ints/bools), removing boolean flags, modeling state machines, or replacing class hierarchies with discriminated unions. Covers records vs DUs, single-case DUs with private constructors and smart constructors (create/value), flags-to-DUs, DUs over inheritance for tree data, and RequireQualifiedAccess. Do not use for trivial DTOs that never evolve, or for shaping types for C# consumers (use design-fsharp-for-dotnet-interop)." +license: MIT +--- + +# F# Domain Modeling + +## Purpose + +Use F#'s type system - records, discriminated unions, and single-case wrappers - so that +invalid data cannot be constructed in the first place. The compiler, not runtime validation, +enforces the rules. + +## When to Use + +- Designing types for a new domain or feature +- Replacing primitive obsession: raw `string`/`int`/`bool` standing in for real concepts +- Removing boolean flags that encode state +- Modeling a workflow or entity that moves through distinct states +- Replacing a class hierarchy used for tree-like or variant data + +## When Not to Use + +- Trivial, stable DTOs that will never grow rules (a plain record is enough) +- Types whose public surface must be consumed from C# - use + `design-fsharp-for-dotnet-interop` + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Domain description or existing types | Yes | What is being modeled, or the types to improve | +| Invariants / rules | No | Constraints the types should make impossible to violate | + +## Core techniques + +### 1. Records for "AND", discriminated unions for "OR" + +A record groups values that all coexist. A DU expresses a choice between alternatives. + +```fsharp +type Customer = { Name: string; Email: EmailAddress } // name AND email +type Contact = Email of EmailAddress | Phone of PhoneNumber // email OR phone +``` + +### 2. Single-case DU + private constructor = a constrained type + +Wrap a primitive so a value of the type is provably valid (the "smart constructor" pattern). +Put `create` (validation) and `value` (extraction) in a module with the same name. + +```fsharp +type EmailAddress = private EmailAddress of string + +[] +module EmailAddress = + let create (s: string) = + if s.Contains "@" then Ok (EmailAddress s) + else Error "email must contain '@'" + + let value (EmailAddress s) = s +``` + +Outside the module the only way to get an `EmailAddress` is through `create`, so every +`EmailAddress` in the system is valid by construction. + +### 3. Replace boolean flags with a discriminated union + +Booleans lose meaning and combine into impossible states. Name the states. + +```fsharp +// Instead of: { IsVerified: bool; IsSuspended: bool } (4 combos, some nonsensical) +type AccountState = + | Unverified + | Active + | Suspended of reason: string +``` + +### 4. Make illegal states unrepresentable: group what changes together + +If two optional fields are only ever both present or both absent, model that. + +```fsharp +// Instead of: { Email: string option; VerifiedAt: DateTime option } +type EmailStatus = + | NotProvided + | Provided of EmailAddress + | Verified of EmailAddress * DateTime +``` + +### 5. DUs over class hierarchies for tree-structured / variant data + +```fsharp +type Expr = + | Const of int + | Add of Expr * Expr + | Mul of Expr * Expr +``` + +Recursive variants are awkward with inheritance and elegant with DUs, and pattern matching +stays exhaustive. + +## Workflow + +1. List the concepts and their invariants. +2. For each "a value that must satisfy a rule", make a single-case DU with a private + constructor and a `create` that returns `Result`. +3. For each "one of several shapes", make a DU; for "several fields together", a record. +4. Collapse boolean flags and parallel `option` fields into DUs that name the real states. +5. Verify: try to write code that constructs an illegal value - it should not compile. + +## Validation + +- [ ] No raw `string`/`int` for concepts with rules (wrapped in constrained types) +- [ ] No public constructor that can build an invalid value (private + `create`) +- [ ] Boolean flags encoding state replaced by named DU cases +- [ ] Parallel `option` fields that vary together collapsed into one DU +- [ ] Tree/variant data modeled as a DU, not an inheritance hierarchy +- [ ] Types compile and illegal construction is rejected by the compiler + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Validation scattered at call sites | Centralize it in the type's `create` smart constructor | +| Public single-case DU constructor | Mark it `private` so validation cannot be bypassed | +| `bool` parameters that encode a mode | Replace with a small DU; reads at the call site | +| Throwing from `create` | Return `Result` so callers handle invalid input (see `fsharp-error-handling`) | +| DU case names collide across types | Add `[]` to the type | + +## More info + +- Designing with types: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions +- Discriminated unions: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/discriminated-unions diff --git a/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md new file mode 100644 index 0000000000..727229c8ad --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md @@ -0,0 +1,148 @@ +--- +name: fsharp-error-handling +description: "Handle expected errors in F# with Result and Option instead of exceptions. Use when adding error handling, input validation, or fallible workflows, or when refactoring exception-driven F# to functional error flow. Covers Result vs Option vs exceptions, Result.bind/map/mapError, the result computation expression, railway-oriented chaining, applicative validation that accumulates multiple errors, Result.sequence/traverse over collections, and tryX vs Xexn naming. Do not use for genuinely exceptional/panic paths or the outermost boundary where exceptions are acceptable." +license: MIT +--- + +# F# Error Handling + +## Purpose + +Represent expected, recoverable errors as values (`Result`, `Option`) that the compiler forces +callers to handle - reserving exceptions for the truly exceptional. + +## When to Use + +- Adding error handling to a function or workflow +- Validating input, especially when multiple fields can each be invalid +- Chaining steps where any step can fail +- Refactoring `try/with`-driven code that uses exceptions for normal control flow + +## When Not to Use + +- Genuinely exceptional, unrecoverable conditions (programmer errors, invariant violations) +- The outermost boundary (e.g. a top-level handler) where catching an exception is the right + move +- Async/Task error propagation specifics - see `fsharp-async-and-tasks` + +## Choosing the representation + +| Situation | Use | +|-----------|-----| +| Operation can fail and the caller needs to know **why** | `Result<'T, 'Error>` | +| Value may be absent and the reason does not matter | `Option<'T>` | +| Truly unexpected / unrecoverable | exception | + +Name functions accordingly: `tryParse` returns `Option`/`Result`; a throwing variant is named +`parseExn` (or documents that it throws). + +## Core techniques + +### 1. Return Result instead of throwing + +```fsharp +let divide x y = + if y = 0 then Error "division by zero" + else Ok (x / y) +``` + +### 2. Chain fallible steps with bind (railway-oriented) + +Each step runs only if the previous succeeded; the first `Error` short-circuits. + +```fsharp +let placeOrder rawInput = + rawInput + |> validateInput + |> Result.bind checkInventory + |> Result.bind chargePayment + |> Result.map buildConfirmation +``` + +### 3. The result computation expression reads better than bind chains + +```fsharp +let placeOrder rawInput = + result { + let! valid = validateInput rawInput + let! stocked = checkInventory valid + let! charged = chargePayment stocked + return buildConfirmation charged + } +``` + +`result { }` ships in libraries such as FsToolkit.ErrorHandling, or can be authored with +`authoring-computation-expressions`. + +### 4. Accumulate multiple errors with applicative validation + +`bind` short-circuits on the first error. For form validation you usually want **all** the +errors. Use a `Validation` (a `Result` whose error side is a list) and apply fields together. + +```fsharp +// validateName : Input -> Validation +// validateAge : Input -> Validation +let validateForm input = + validation { + let! name = validateName input + and! age = validateAge input + return { Name = name; Age = age } + } +// both validators run; errors collected into a list +``` + +`and!` (not `let!`) is what makes the validators independent and accumulating. + +### 5. Flip a list of Results: sequence / traverse + +Turn `Result list` into a single `Result` of a list (fails if any element fails). + +```fsharp +let parseAll lines = + lines + |> List.map parseLine // string list -> Result list + |> List.sequenceResultM // -> Result +``` + +### 6. Convert exceptions at the boundary + +When calling a .NET API that throws, catch once and convert to `Result`: + +```fsharp +let readConfig path = + try Ok (System.IO.File.ReadAllText path) + with :? System.IO.IOException as ex -> Error ex.Message +``` + +## Workflow + +1. Decide `Result` vs `Option` vs exception for each fallible operation. +2. Make functions return that value instead of throwing. +3. Compose steps with `result { }` (sequential) or `validation { }` with `and!` (accumulating). +4. For collections of fallible results, use `sequence`/`traverse`. +5. Wrap throwing .NET calls at the edges; let `Result` flow inward. +6. Verify the happy path and at least one failing path with `dotnet fsi`. + +## Validation + +- [ ] Expected errors are `Result`/`Option`, not exceptions +- [ ] Chains use `Result.bind`/`result { }`, not nested matches +- [ ] Multi-field validation accumulates errors with `and!` (not first-error short-circuit) +- [ ] Collections of results handled with `sequence`/`traverse` +- [ ] Throwing .NET APIs converted to `Result` at the boundary +- [ ] `tryX` vs `Xexn` naming reflects whether a function can throw + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Using `bind` for form validation | Use applicative `validation { }` with `and!` to collect all errors | +| `Option` where the caller needs the reason | Use `Result` with a descriptive error | +| Stringly-typed errors everywhere | Consider a DU error type (see `fsharp-domain-modeling`) | +| `failwith` for ordinary validation | Return `Error`; reserve exceptions for the unexpected | +| Catching `System.Exception` broadly | Catch the specific exception type you can handle | + +## More info + +- Error management conventions: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions +- Result type: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/results diff --git a/plugins/dotnet-fsharp/skills/fsharp-project-organization/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-project-organization/SKILL.md new file mode 100644 index 0000000000..9abadcbec7 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-project-organization/SKILL.md @@ -0,0 +1,135 @@ +--- +name: fsharp-project-organization +description: "Organize F# projects correctly given that F# compilation is order-dependent. Use when creating or restructuring an .fsproj, fixing 'value or constructor is not defined' errors caused by file/declaration order, deciding between namespace and module, adding signature (.fsi) files, or applying RequireQualifiedAccess/AutoOpen. Covers explicit Compile ordering in the .fsproj, top-to-bottom no-forward-reference rule, namespace vs module, signature files, and layering. Do not use for general build/MSBuild tuning (use the dotnet-msbuild skills)." +license: MIT +--- + +# F# Project Organization + +## Purpose + +F# resolves names strictly in order - within a file, top to bottom, and across files, in the +order they appear in the `.fsproj`. Getting that order and the namespace/module structure right +prevents a whole class of "not defined" errors and keeps the codebase navigable. + +## When to Use + +- Creating or restructuring an `.fsproj` +- Diagnosing "The value or constructor 'X' is not defined" caused by ordering +- Choosing between a `namespace` and a `module` at the top of a file +- Introducing signature (`.fsi`) files to lock down a public API +- Applying `[]` / `[]` + +## When Not to Use + +- General MSBuild/build performance or packaging - use the `dotnet-msbuild` skills + +## The order rule + +There are no forward references in F#. A name must be **defined above its use**: + +- Within a file: declarations are read top to bottom. +- Across files: the order is the order of `` items in the `.fsproj`, not + alphabetical and not folder order. + +```xml + + + + + + +``` + +Reorder these items to fix ordering errors; do not try to add forward declarations. + +## namespace vs module at the top of a file + +| | `namespace` | `module` (top-level) | +|---|------------|----------------------| +| Spans multiple files | yes | no (one file) | +| Can hold functions directly | no (only types, or an inner module) | yes | +| Best for | grouping types across files | a file that is mostly functions | + +```fsharp +namespace Fabrikam.Domain + +type Customer = { Name: string } + +module Customer = // inner module for functions over the type + let rename name c = { c with Name = name } +``` + +```fsharp +module Fabrikam.Utilities // a function-heavy file + +let add x y = x + y +``` + +## Signature files (.fsi) + +A `.fsi` file sits immediately before its `.fs` file in the `.fsproj` and defines the public +surface; everything not listed is private. + +```fsharp +// Customer.fsi +namespace Fabrikam.Domain + +type Customer = { Name: string } + +module Customer = + val rename: string -> Customer -> Customer +``` + +Introduce signature files once an API is stable - they add friction (changes must be made in +both files) but give a clean, enforced public surface. + +## RequireQualifiedAccess and AutoOpen + +- `[]` on a module forces callers to qualify (`Order.create`), avoiding + name collisions and ambiguity from `open` ordering. Use it for modules that shadow or extend + `FSharp.Core` modules (a custom `Result`/`List`-like module). +- `[]` opens a module automatically with its namespace. Use sparingly - it pollutes + scope. Good for a small operators module or extension members. + +## Suggested layering + +Modules reference only modules above them. A common bottom-to-top order: + +``` +Common / primitives -> Domain types -> Validation -> Workflows + -> DTOs / serialization -> Infrastructure -> API -> Composition root (Program.fs) +``` + +## Workflow + +1. List types and functions; identify dependencies (who uses whom). +2. Order `.fsproj` `` items so every definition precedes its use. +3. Choose `namespace` (types across files) or `module` (function-heavy file) per file. +4. Apply `[]` where names collide; `[]` only where justified. +5. Add `.fsi` files for stable public APIs. +6. Verify with `dotnet build`. + +## Validation + +- [ ] `.fsproj` `` order places every definition before its use +- [ ] No "value or constructor is not defined" errors from ordering +- [ ] Each file's top-level `namespace`/`module` choice fits its contents +- [ ] Collision-prone modules carry `[]` +- [ ] Signature files (if used) precede their `.fs` files and compile +- [ ] `dotnet build` succeeds + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Assuming alphabetical/folder order compiles | Order is the explicit `.fsproj` `` list | +| Trying to forward-reference a later type | Move the definition earlier in file/project order | +| `module` at file top when types should span files | Use a `namespace` with inner modules | +| Overusing `[]` | Reserve for small operator/extension modules | +| Adding `.fsi` to a still-churning API | Wait until the API stabilizes | + +## More info + +- F# project structure / compiler order: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/component-design-guidelines +- Signature files: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/signature-files diff --git a/plugins/dotnet-fsharp/skills/fsharp-scripts/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-scripts/SKILL.md new file mode 100644 index 0000000000..e1ec0aa846 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-scripts/SKILL.md @@ -0,0 +1,158 @@ +--- +name: fsharp-scripts +description: "Run F# scripts (.fsx) with F# Interactive (dotnet fsi) when the user wants to experiment with F# without creating a project. Use for trying an F# language feature or API, prototyping logic before integrating it, one-file F# utilities, or referencing NuGet packages and other scripts inline. Covers dotnet fsi, #r \"nuget:\", #load, #r for DLLs, fsi.CommandLineArgs, #time, and %A printing. Do not use for full projects/solutions, language-agnostic throwaway scripts, or integrating code into an existing .NET solution." +license: MIT +--- + +# F# Scripts with F# Interactive + +## When to Use + +- Testing an F# concept, API, or language feature quickly +- Prototyping logic before integrating it into a larger project +- Building a small one-file utility +- Exploring a NuGet package interactively + +## When Not to Use + +- The user asks for a language-agnostic quick script or throwaway computation +- The user needs a full project, solution integration, or library +- The user is working inside an existing .NET solution and wants to add code there + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| F# code or intent | Yes | The code to run, or a description of what the script should do | + +## Workflow + +### Step 1: Check the .NET SDK + +```bash +dotnet --version +``` + +F# Interactive ships with the .NET SDK. `dotnet fsi` is available with any modern SDK; no extra +install is required. + +### Step 2: Write the script + +Create a `.fsx` file. Top-level code runs in order, top to bottom. + +```fsharp +// hello.fsx +let numbers = [ 1; 2; 3; 4; 5 ] +let sum = numbers |> List.sum +printfn "Sum: %d" sum +``` + +`.fsx` is a script (runs directly). `.fs` is a compiled source file (belongs to a project). Use +`.fsx` here. + +### Step 3: Run it + +```bash +dotnet fsi hello.fsx +``` + +Pass arguments after the script path and read them with `fsi.CommandLineArgs`: + +```bash +dotnet fsi hello.fsx -- alpha beta +``` + +```fsharp +let args = fsi.CommandLineArgs // args.[0] is the script name +printfn "%A" args.[1..] +``` + +### Step 4: Reference packages and files (directives) + +Script directives start with `#`. Place them at the top. + +#### `#r "nuget:"` - NuGet packages + +```fsharp +#r "nuget: FSharp.Data, 6.4.0" +open FSharp.Data +``` + +Omit the version to take the latest, but pin a version for reproducibility. + +#### `#r` - a DLL by path + +```fsharp +#r "../bin/Debug/net10.0/MyLib.dll" +``` + +#### `#load` - another script or source file + +```fsharp +#load "Helpers.fsx" +Helpers.greet "world" +``` + +`#load` brings the file's definitions into the session. Loaded files run in order. + +#### `#time "on"` - timing + +```fsharp +#time "on" +// subsequent evaluations print real/CPU time +``` + +### Step 5: Print results + +- `printfn "%d / %s / %f"` - typed format specifiers (compiler-checked) +- `%A` - structured pretty-print for any F# value (records, DUs, lists) +- `printfn "%A" value` is the quickest way to inspect domain data + +### Step 6: Clean up + +Remove the `.fsx` files when the user is done. + +## Unix shebang support + +Make a `.fsx` directly executable on Unix: + +```fsharp +#!/usr/bin/env -S dotnet fsi +printfn "I'm executable!" +``` + +```bash +chmod +x hello.fsx +./hello.fsx +``` + +Use `LF` line endings (not `CRLF`) for shebang scripts. + +## Interactive REPL + +Start a bare REPL with `dotnet fsi`. In the REPL, terminate an expression with `;;` to evaluate +it. `#help;;` lists directives; `#quit;;` exits. + +## Validation + +- [ ] `dotnet --version` succeeds (SDK present) +- [ ] The script is a `.fsx` file with top-level code +- [ ] `dotnet fsi .fsx` produces the expected output +- [ ] Any `#r "nuget:"` reference resolves and the package is usable +- [ ] Multi-file scripts wire up with `#load` and run in order +- [ ] Script files are cleaned up after the session + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Using `.fs` instead of `.fsx` | Scripts run with `dotnet fsi` must be `.fsx` | +| `#r "nuget:"` without internet/feed | Ensure NuGet feed access; pin a version for reproducibility | +| Directives placed after code | All `#` directives go at the top of the file | +| Forward references | F# scripts run top to bottom; define before use | +| `CRLF` on a shebang script | Use `LF` line endings; the shebang is ignored on Windows | +| Expecting `Main` / namespaces | Scripts use top-level statements; namespaces are not available in `.fsx` | + +## More info + +- F# Interactive: https://learn.microsoft.com/en-us/dotnet/fsharp/tools/fsharp-interactive/ diff --git a/plugins/dotnet-fsharp/skills/fsharp-type-providers/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-type-providers/SKILL.md new file mode 100644 index 0000000000..04d184bfa8 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-type-providers/SKILL.md @@ -0,0 +1,90 @@ +--- +name: fsharp-type-providers +description: "Use F# type providers to get strongly-typed access to external data (JSON, CSV, XML, HTML, SQL) without hand-writing types. Use when reading structured data from a sample or live source and you want compile-time-checked properties instead of stringly-typed parsing. Covers FSharp.Data providers (JsonProvider, CsvProvider, HtmlProvider, XmlProvider), referencing them from scripts and projects, sample vs live schemas, and runtime caveats. Do not use for trivial one-field parsing, or when AOT/trimming forbids the provider's generated code." +license: MIT +--- + +# F# Type Providers + +## Purpose + +A type provider generates types at compile time from an external schema or sample, so you can +read JSON/CSV/XML/SQL with dotted, IntelliSense-backed property access instead of manual, +error-prone parsing. + +## When to Use + +- Reading structured external data (JSON, CSV, XML, HTML, a database) +- You have a representative sample or a live endpoint to infer the shape from +- You want compile-time-checked access to fields rather than string keys + +## When Not to Use + +- Trivial parsing of one or two fields (plain parsing is simpler) +- Native AOT / aggressive trimming contexts where provider-generated code is unsupported +- A fixed, well-known contract you would rather model explicitly (see `fsharp-domain-modeling`) + +## FSharp.Data providers + +Add the `FSharp.Data` package (`#r "nuget: FSharp.Data"` in a script - see `fsharp-scripts`). + +### JsonProvider + +```fsharp +#r "nuget: FSharp.Data" +open FSharp.Data + +type Weather = JsonProvider<""" { "city": "Oslo", "tempC": 12.5 } """> + +let sample = Weather.Parse(""" { "city": "Bergen", "tempC": 9.0 } """) +printfn "%s is %f C" sample.City sample.TempC // strongly typed +``` + +The string literal is a **sample** used only to infer the type; real data is parsed at runtime. +You can also point at a file or URL: `JsonProvider<"sample.json">` or +`JsonProvider<"https://...">`. + +### CsvProvider + +```fsharp +type Stocks = CsvProvider<"Date,Open,Close\n2020-01-01,100.0,101.5"> + +let data = Stocks.Load("stocks.csv") +for row in data.Rows do + printfn "%A closed at %f" row.Date row.Close +``` + +### XmlProvider / HtmlProvider + +`XmlProvider<...>` and `HtmlProvider<...>` work the same way: give a sample (literal, file, or +URL); access elements/tables as typed members. + +## Workflow + +1. Reference `FSharp.Data` (`#r "nuget:"` in a script, or a package reference in a project). +2. Pick the provider for the format (Json/Csv/Xml/Html). +3. Provide a representative sample (literal, file path, or URL) to the provider type. +4. Parse/Load real data and access fields through the generated typed members. +5. Verify with `dotnet fsi` against actual data. + +## Validation + +- [ ] The right provider is used for the data format +- [ ] A representative sample defines the schema +- [ ] Fields are accessed via generated typed members, not string keys +- [ ] Real data parses and reads correctly (`dotnet fsi`) +- [ ] AOT/trimming constraints checked if the project targets them + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Sample not representative of real data | Use a sample covering all fields and nullability | +| Expecting providers under native AOT | Provider-generated types are generally unsupported there | +| Treating the sample as the data | The sample only infers types; parse/load the real source | +| Huge live sample fetched at build time | Prefer a small local sample file for reproducible builds | + +## More info + +- Type providers: https://learn.microsoft.com/en-us/dotnet/fsharp/tutorials/type-providers/ +- FSharp.Data: https://fsprojects.github.io/FSharp.Data/ diff --git a/plugins/dotnet-fsharp/skills/fsharp-units-of-measure/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-units-of-measure/SKILL.md new file mode 100644 index 0000000000..4450e39c75 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/fsharp-units-of-measure/SKILL.md @@ -0,0 +1,112 @@ +--- +name: fsharp-units-of-measure +description: "Add compile-time unit-of-measure safety to numeric F# code so quantities with different units cannot be mixed by mistake. Use when modeling physical quantities, currency, or any dimensioned numbers (meters, seconds, kg, USD), or to catch unit-mismatch bugs at compile time. Covers declaring measures with [], annotating literals and types, derived units, conversions, and the fact that measures are erased at runtime and invisible to other .NET languages. Do not use when values are plain dimensionless numbers." +license: MIT +--- + +# F# Units of Measure + +## Purpose + +Units of measure attach a dimension (meters, seconds, USD) to numeric types so the compiler +rejects nonsensical operations - adding meters to seconds, or passing a distance where a time is +expected - with zero runtime cost. + +## When to Use + +- Modeling physical quantities (length, time, mass, speed) or money +- Preventing unit-mismatch bugs in numeric-heavy code +- Making conversion functions explicit and type-safe + +## When Not to Use + +- Plain dimensionless numbers +- Public API surface intended for C# consumers (units are erased and invisible there - see + `design-fsharp-for-dotnet-interop`) + +## Declaring and using measures + +```fsharp +[] type m // meters +[] type s // seconds +[] type kg // kilograms + +let distance = 100.0 +let time = 9.58 +``` + +### Operations carry units automatically + +```fsharp +let speed = distance / time // float +``` + +Mixing units is a compile error: + +```fsharp +// let bad = distance + time // error: the unit 'm' does not match 's' +``` + +### Derived units + +```fsharp +[] type N = kg m / s^2 // newton, defined from base units +``` + +### Annotating function signatures + +```fsharp +let kineticEnergy (mass: float) (v: float) : float = + 0.5 * mass * v * v +``` + +### Conversions are explicit + +Define conversion constants with the right compound unit: + +```fsharp +[] type ft +let feetPerMeter = 3.28084 +let toFeet (d: float) : float = d * feetPerMeter +``` + +### Adding and removing units + +```fsharp +let raw : float = 5.0 +let typed = raw * 1.0 // add a unit +let back : float = float typed // strip units back to plain float +``` + +`LanguagePrimitives.FloatWithMeasure` is the explicit way to attach a measure to a computed +value. + +## Workflow + +1. Declare a `[]` type per base unit the domain uses. +2. Annotate literals (`100.0`) and function parameters/returns. +3. Define derived units from base ones where helpful. +4. Make every conversion an explicit function with the compound unit in its type. +5. Strip units only at boundaries that need plain numbers. +6. Verify mismatches are caught (`dotnet fsi` - the bad line should fail to compile). + +## Validation + +- [ ] Base units declared with `[]` +- [ ] Quantities annotated at literals and signatures +- [ ] Mixing incompatible units fails to compile +- [ ] Conversions are explicit, typed functions +- [ ] Units stripped only where plain numbers are required + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Expecting units to appear in a C# consumer | Measures are erased at runtime; C# sees `float` | +| Reusing `float` raw then re-adding units ad hoc | Keep values typed end-to-end; convert explicitly | +| Hard-coding conversions inline | Define typed conversion functions/constants | +| Using measures on dimensionless values | Only annotate genuinely dimensioned quantities | + +## More info + +- Units of measure: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure diff --git a/plugins/dotnet-fsharp/skills/testing-fsharp/SKILL.md b/plugins/dotnet-fsharp/skills/testing-fsharp/SKILL.md new file mode 100644 index 0000000000..a01581b608 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/testing-fsharp/SKILL.md @@ -0,0 +1,122 @@ +--- +name: testing-fsharp +description: "Write idiomatic F# tests, including property-based tests. Use when adding unit tests for F# code, choosing an F# test framework (Expecto, or xUnit/NUnit with FsUnit), or introducing property-based testing with FsCheck. Covers Expecto test lists, xUnit-with-F# style, FsUnit assertions, and FsCheck properties/generators. Do not use for general .NET test running, filtering, or migration - use the dotnet-test plugin skills for those." +license: MIT +--- + +# Testing F# + +## Purpose + +Write tests that read naturally in F# and exploit F#'s strengths - especially property-based +testing, which checks invariants across many generated inputs instead of a handful of examples. + +## When to Use + +- Adding unit tests for F# code +- Choosing an F# test approach (Expecto vs xUnit/NUnit + FsUnit) +- Introducing property-based tests with FsCheck + +## When Not to Use + +- Running/filtering/migrating .NET tests generally - use the `dotnet-test` plugin skills +- Pure C# test projects + +## Approach 1: Expecto (F#-first) + +Expecto models tests as plain values (`test "..." { ... }`) in a `testList`, run from `main`. + +```fsharp +open Expecto + +let tests = + testList "math" [ + test "addition is commutative for two examples" { + Expect.equal (2 + 3) (3 + 2) "should be equal" + } + test "list reverse twice is identity" { + let xs = [ 1; 2; 3 ] + Expect.equal (xs |> List.rev |> List.rev) xs "round-trips" + } + ] + +[] +let main argv = runTestsWithCLIArgs [] argv tests +``` + +## Approach 2: xUnit/NUnit + FsUnit + +Familiar if the rest of the solution uses xUnit. FsUnit gives F#-readable assertions. + +```fsharp +open Xunit +open FsUnit.Xunit + +[] +let ``reversing twice returns the original`` () = + [ 1; 2; 3 ] |> List.rev |> List.rev |> should equal [ 1; 2; 3 ] +``` + +Backtick-quoted names give readable test descriptions. + +## Property-based testing with FsCheck + +Instead of fixed examples, state a property that must hold for **all** inputs; FsCheck generates +many cases (and shrinks failures to a minimal counterexample). + +```fsharp +open FsCheck + +let ``reverse twice is identity`` (xs: int list) = + List.rev (List.rev xs) = xs + +Check.Quick ``reverse twice is identity`` +``` + +With Expecto, use `testProperty`: + +```fsharp +testProperty "addition is commutative" (fun (a: int) (b: int) -> a + b = b + a) +``` + +Good properties to look for: round-trips (encode/decode), invariants (length preserved), +commutativity/associativity, and equivalence to a simple reference implementation. + +### Custom generators + +When the default generator produces invalid inputs, constrain it: + +```fsharp +let positiveInts = Arb.generate |> Gen.filter (fun n -> n > 0) |> Arb.fromGen +``` + +## Workflow + +1. Choose Expecto (F#-first) or xUnit/NUnit + FsUnit (matches existing solution). +2. Write example-based tests for specific known cases. +3. Add property-based tests for invariants/round-trips with FsCheck. +4. Add custom generators where the domain restricts valid inputs. +5. Run with `dotnet test` (or the Expecto runner) and confirm green; see the `dotnet-test` + skills for running and filtering. + +## Validation + +- [ ] Tests compile and run green +- [ ] Invariants/round-trips covered by property-based tests, not just examples +- [ ] Generators restricted where the domain requires valid inputs +- [ ] Test names read as clear descriptions + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Only example-based tests for code with clear invariants | Add FsCheck properties | +| Property fails on inputs the code never accepts | Add a custom generator / precondition | +| Mixing frameworks arbitrarily across a solution | Pick one approach per test project | +| Re-deriving the implementation inside the property | Compare against a simpler reference or an invariant | + +## More info + +- Unit testing in .NET: https://learn.microsoft.com/en-us/dotnet/core/testing/ +- FsCheck: https://fscheck.github.io/FsCheck/ +- Expecto: https://github.com/haf/expecto diff --git a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md new file mode 100644 index 0000000000..cc4d595aa2 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md @@ -0,0 +1,121 @@ +--- +name: writing-idiomatic-fsharp +description: "Write or refactor F# in idiomatic functional style instead of transliterated C#. Use when authoring new F#, refactoring F# that reads like C# (mutable variables, for/while loops, null, if/else statements, classes, .Result/.Wait()), or reviewing F# for idiom. Covers expressions over statements, immutability, |> and >> pipelines, pattern matching, collection functions over loops, Option over null, partial application, and exhaustive matching. Do not use for deliberate hot-path mutation, or for shaping an F# public API for C# consumers (use design-fsharp-for-dotnet-interop)." +license: MIT +--- + +# Writing Idiomatic F# + +## Purpose + +Produce F# that an experienced F# developer would write: expression-oriented, immutable by +default, composed with pipelines, and driven by pattern matching - not C# control flow ported +into `.fs` files. + +## When to Use + +- Authoring new F# code where idiom and readability matter +- Refactoring F# that "reads like C#": mutable accumulators, `for`/`while` loops, `null`, + statement-style `if/else`, classes where a record or discriminated union fits +- Reviewing F# for non-idiomatic patterns before merge + +## When Not to Use + +- Deliberate performance-critical mutation (tight loops, pooled buffers); idiom yields to + measured performance there +- Shaping an F# public API for consumption by C# or other .NET languages - use + `design-fsharp-for-dotnet-interop` +- Pure formatting concerns - use `format-fsharp-with-fantomas` + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| F# code or intent | Yes | Code to refactor, or a description of what to write | +| Idiom scope | No | Optional focus, e.g. "just the loops" or "just the error flow" | + +## Workflow + +### Step 1: Identify the C#-isms + +Scan for these tells - the most common ways C# habits leak into F#: + +- `mutable` bindings and reassignment used as accumulators +- `for` / `while` loops iterating a collection to build a result +- `null` literals and null checks on F# types +- `if/else` used as a statement to pick a value +- `class` with mutable fields where a `record` or discriminated union fits +- `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` on tasks +- Nested `if` / type-tests that should be a single `match` +- `match` with a stray `_` over a closed set of cases + +### Step 2: Apply the idiom rewrites + +Rewrite one concern at a time. See `references/csharpism-rewrites.md` for the full before/after +catalog. The core moves: + +- Statements to expressions: `if cond then a else b` and `match` are values, not control flow +- Mutation to immutable bindings, recursion, or `List.fold` +- Loops to `List`/`Seq`/`Array` functions: `map`, `filter`, `choose`, `fold`, `sumBy` +- `null` to `Option` (`Some`/`None`), composed with `Option.map` / `Option.defaultValue` +- Nested calls to `|>` pipelines; use `>>` composition only where it stays readable +- Flags and type-tests to `match` and discriminated unions +- `.Result` to proper `async`/`task` (see `fsharp-async-and-tasks` for depth) + +### Step 3: Tighten pattern matching + +Make matches exhaustive over closed types and remove stray wildcards, so the compiler warns +when a new case is added later. + +### Step 4: Apply open hygiene + +Keep `open` statements minimal and close to use. Suggest `[]` on +modules whose names would collide (for example a custom `Result`-like module). + +### Step 5: Verify it compiles + +Round-trip the rewrite through F# Interactive or the build so it actually compiles: + +```bash +dotnet fsi rewrite.fsx +# or, inside a project: +dotnet build +``` + +See the `fsharp-scripts` skill for the `.fsx` workflow. + +## Quick reference: highest-value rewrites + +| C#-ism | Idiomatic F# | +|--------|--------------| +| `let mutable sum = 0` + `for x in xs do sum <- sum + x` | `xs \|> List.sum` | +| `let mutable acc = []` + loop with `acc <- f x :: acc` | `xs \|> List.map f` | +| `if x <> null then f x else d` | `x \|> Option.map f \|> Option.defaultValue d` | +| `let r = if c then a else b` (statement style) | `let r = if c then a else b` (used as a value, single expression) | +| `g(f(x))` | `x \|> f \|> g` | +| nested `if/else if` over a closed set | `match value with ...` | + +## Validation + +- [ ] No `mutable` remains except where mutation is deliberate and justified +- [ ] No `for`/`while` loop that merely builds a value (replaced by `map`/`filter`/`fold`) +- [ ] No `null` on F# types (replaced by `Option`) +- [ ] `if`/`match` used as expressions, not statements +- [ ] Matches over closed types are exhaustive (no stray `_`) +- [ ] The rewrite compiles (`dotnet fsi` or `dotnet build`) + +## Common Pitfalls + +| Pitfall | Correction | +|---------|------------| +| Making everything point-free with `>>` | Keep `>>` only where it stays readable; use named pipelines otherwise | +| Removing every `_` blindly | Wildcards are fine for genuinely open inputs; only closed, enumerable cases must be explicit | +| "Idiomatic" rewrite that no longer compiles | Always round-trip through `dotnet fsi` / `dotnet build` | +| Replacing a deliberate `mutable` hot path | Respect performance-motivated mutation; this skill targets defaults, not micro-optimized code | +| Over-`open`-ing to shorten names | Prefer `[]` plus a local `open` | +| Deep nested pipelines no one can read | Break into named intermediate bindings with meaningful names | + +## More info + +- F# style guide: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/ +- F# coding conventions: https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions diff --git a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md new file mode 100644 index 0000000000..b89a075ad4 --- /dev/null +++ b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md @@ -0,0 +1,211 @@ +# C#-ism to idiomatic F# rewrites + +A before/after catalog for the most common ways C# habits leak into F#. Each pair compiles. + +## 1. Loop + mutable accumulator to a collection function + +C#-in-F#: + +```fsharp +let sumOfSquares xs = + let mutable total = 0 + for x in xs do + total <- total + x * x + total +``` + +Idiomatic: + +```fsharp +let sumOfSquares xs = xs |> List.sumBy (fun x -> x * x) +``` + +## 2. Building a list by mutation to map/filter/choose + +C#-in-F#: + +```fsharp +let evensDoubled xs = + let mutable result = [] + for x in xs do + if x % 2 = 0 then + result <- (x * 2) :: result + List.rev result +``` + +Idiomatic: + +```fsharp +let evensDoubled xs = + xs + |> List.filter (fun x -> x % 2 = 0) + |> List.map (fun x -> x * 2) +``` + +When the test and the projection are one step, prefer `List.choose`: + +```fsharp +let parsedValues xs = + xs |> List.choose (fun s -> match System.Int32.TryParse s with + | true, v -> Some v + | _ -> None) +``` + +## 3. null to Option + +C#-in-F#: + +```fsharp +let displayName user = + if user.Name <> null then user.Name.ToUpper() + else "ANONYMOUS" +``` + +Idiomatic (model absence as `Option`, not `null`): + +```fsharp +let displayName user = + user.Name + |> Option.map (fun n -> n.ToUpper()) + |> Option.defaultValue "ANONYMOUS" +``` + +For .NET APIs that really return null, convert at the boundary with `Option.ofObj`. + +## 4. Statement-style if to an expression + +C#-in-F#: + +```fsharp +let classify n = + let mutable label = "" + if n < 0 then label <- "negative" + elif n = 0 then label <- "zero" + else label <- "positive" + label +``` + +Idiomatic (the whole `if`/`elif`/`else` is one value): + +```fsharp +let classify n = + if n < 0 then "negative" + elif n = 0 then "zero" + else "positive" +``` + +## 5. Nested if / type-tests to match + +C#-in-F#: + +```fsharp +let describe shape = + if shape.Kind = "circle" then sprintf "circle r=%f" shape.Radius + elif shape.Kind = "rect" then sprintf "rect %fx%f" shape.W shape.H + else "unknown" +``` + +Idiomatic (model the shape as a DU, then match - see `fsharp-domain-modeling`): + +```fsharp +type Shape = + | Circle of radius: float + | Rect of width: float * height: float + +let describe shape = + match shape with + | Circle r -> sprintf "circle r=%f" r + | Rect (w, h) -> sprintf "rect %fx%f" w h +``` + +## 6. Nested calls to a pipeline + +C#-in-F#: + +```fsharp +let result = List.sum (List.map square (List.filter isPositive numbers)) +``` + +Idiomatic: + +```fsharp +let result = + numbers + |> List.filter isPositive + |> List.map square + |> List.sum +``` + +## 7. .Result / .Wait() to async/task + +C#-in-F#: + +```fsharp +let content = httpClient.GetStringAsync(url).Result +``` + +Idiomatic (do not block; stay in the async context - see `fsharp-async-and-tasks`): + +```fsharp +let fetch url = + task { + let! content = httpClient.GetStringAsync(url) + return content + } +``` + +## 8. Manual recursion to fold + +C#-in-F#: + +```fsharp +let rec total xs = + match xs with + | [] -> 0 + | head :: tail -> head + total tail +``` + +Idiomatic when it is a straight reduction: + +```fsharp +let total xs = xs |> List.fold (+) 0 +``` + +(Hand-written recursion is still idiomatic when the traversal is not a simple fold.) + +## 9. Class with mutable fields to a record + +C#-in-F#: + +```fsharp +type Point() = + member val X = 0.0 with get, set + member val Y = 0.0 with get, set +``` + +Idiomatic for plain data (immutable, structural equality, `with` for updates): + +```fsharp +type Point = { X: float; Y: float } + +let moved p dx dy = { p with X = p.X + dx; Y = p.Y + dy } +``` + +## 10. Exhaustive matching over a stray wildcard + +Fragile - a new case is silently mishandled: + +```fsharp +match status with +| Active -> "on" +| _ -> "off" +``` + +Robust - the compiler warns when `Status` gains a case: + +```fsharp +match status with +| Active -> "on" +| Suspended -> "off" +| Closed -> "off" +``` diff --git a/tests/dotnet-fsharp/authoring-computation-expressions/eval.yaml b/tests/dotnet-fsharp/authoring-computation-expressions/eval.yaml new file mode 100644 index 0000000000..2e1fad3500 --- /dev/null +++ b/tests/dotnet-fsharp/authoring-computation-expressions/eval.yaml @@ -0,0 +1,25 @@ +scenarios: + - name: "Author a result computation expression" + prompt: | + In F#, I keep writing Result.bind chains. Author a custom 'result' computation expression + builder (do not pull in a NuGet library) so I can write let!/return over Result, then show + a two-step computation that short-circuits on the first Error, using dotnet fsi. + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "member" + - type: "output_matches" + pattern: "(Bind|Return)" + expect_tools: ["bash"] + rubric: + - "Defines a builder type with Bind and Return members with correct Result semantics" + - "Instantiates it as a lowercase 'result' value and uses a result { } block" + - "Demonstrates first-Error short-circuiting via dotnet fsi" + timeout: 180 + + - name: "Does not over-engineer when a library or pipeline suffices" + prompt: "I have a single two-step Option chain: parse a string then look it up in a map. What's the simplest idiomatic F#?" + expect_activation: false + rubric: + - "Recommends a plain Option.bind / pipeline rather than authoring a custom computation expression for a one-off two-step chain" + timeout: 120 diff --git a/tests/dotnet-fsharp/convert-csharp-to-fsharp/eval.yaml b/tests/dotnet-fsharp/convert-csharp-to-fsharp/eval.yaml new file mode 100644 index 0000000000..71f4364519 --- /dev/null +++ b/tests/dotnet-fsharp/convert-csharp-to-fsharp/eval.yaml @@ -0,0 +1,47 @@ +scenarios: + - name: "Port a C# class to idiomatic F#" + prompt: | + Convert this C# to idiomatic F# (not a line-by-line port), then run the F# with dotnet fsi + to show it works: + + public class Money { + public decimal Amount { get; } + public string Currency { get; } + public Money(decimal amount, string currency) { Amount = amount; Currency = currency; } + public Money Add(Money other) { + if (other.Currency != Currency) throw new InvalidOperationException("currency mismatch"); + return new Money(Amount + other.Amount, Currency); + } + } + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(type Money|Result|Ok|Error)" + - type: "output_not_matches" + pattern: "(get; set;|public class)" + expect_tools: ["bash"] + rubric: + - "Uses an immutable record (or single-case type) instead of a mutable C# class" + - "Replaces the thrown exception for currency mismatch with a Result (Ok/Error)" + - "Ran the F# with dotnet fsi demonstrating both same-currency add and the mismatch case" + timeout: 240 + + - name: "Map a C# enum and switch to F#" + prompt: | + Convert this C# to idiomatic F#: + + enum Direction { North, South, East, West } + string Describe(Direction d) { + switch (d) { + case Direction.North: return "up"; + case Direction.South: return "down"; + default: return "sideways"; + } + } + assertions: + - type: "output_matches" + pattern: "match" + rubric: + - "Models Direction as a discriminated union (or enum) and uses match instead of switch" + - "Handles all cases (ideally exhaustively rather than a catch-all where cases are closed)" + timeout: 150 diff --git a/tests/dotnet-fsharp/design-fsharp-for-dotnet-interop/eval.yaml b/tests/dotnet-fsharp/design-fsharp-for-dotnet-interop/eval.yaml new file mode 100644 index 0000000000..39f8e1fbdb --- /dev/null +++ b/tests/dotnet-fsharp/design-fsharp-for-dotnet-interop/eval.yaml @@ -0,0 +1,28 @@ +scenarios: + - name: "Make an F# API consumable from C#" + prompt: | + I have this F# that I want to expose to C# consumers: + + module Calc + let add x y = x + y + let tryParse (s: string) : int option = match System.Int32.TryParse s with true, v -> Some v | _ -> None + + Redesign the public surface so it is natural to call from C#. Explain each change. + assertions: + - type: "output_matches" + pattern: "(static member|AbstractClass|TryGetValue|Func)" + rubric: + - "Replaces the module of let-functions with a static-member type (e.g. [])" + - "Uses tupled parameters (Add(x, y)) rather than curried" + - "Replaces the option return with a TryGetValue-style bool + out parameter" + timeout: 150 + + - name: "Convert an Async-returning member to Task" + prompt: "In F#, I have a member that returns Async but C# callers need it. How should the public member look? Show it." + assertions: + - type: "output_matches" + pattern: "(Task|StartAsTask)" + rubric: + - "Public member returns Task, e.g. via Async.StartAsTask" + - "Mentions accepting/forwarding a CancellationToken" + timeout: 120 diff --git a/tests/dotnet-fsharp/format-fsharp-with-fantomas/eval.yaml b/tests/dotnet-fsharp/format-fsharp-with-fantomas/eval.yaml new file mode 100644 index 0000000000..e566a1d1ee --- /dev/null +++ b/tests/dotnet-fsharp/format-fsharp-with-fantomas/eval.yaml @@ -0,0 +1,23 @@ +scenarios: + - name: "Format F# with Fantomas" + prompt: "How do I format all the F# files in my repo's src/ directory with Fantomas, pinned per-repo, and add a formatting check to CI?" + assertions: + - type: "output_matches" + pattern: "fantomas" + - type: "output_matches" + pattern: "(--check|tool-manifest|tool install)" + rubric: + - "Installs Fantomas as a pinned local dotnet tool (tool manifest)" + - "Runs dotnet fantomas on src/ to format" + - "Uses dotnet fantomas --check for the CI gate" + timeout: 120 + + - name: "Configure a style deviation via .editorconfig" + prompt: "I want F# lines to wrap at 100 characters instead of the Fantomas default. Where and how do I set that?" + assertions: + - type: "output_matches" + pattern: "(editorconfig|max_line_length)" + rubric: + - "Sets max_line_length = 100 in an [*.fs] (or *.{fs,fsx,fsi}) section of .editorconfig" + - "Notes Fantomas reads style from .editorconfig" + timeout: 120 diff --git a/tests/dotnet-fsharp/fsharp-active-patterns/eval.yaml b/tests/dotnet-fsharp/fsharp-active-patterns/eval.yaml new file mode 100644 index 0000000000..60a7b7d901 --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-active-patterns/eval.yaml @@ -0,0 +1,28 @@ +scenarios: + - name: "Turn a string classifier into an active pattern match" + prompt: | + In F#, I want to classify a string as either a valid integer, a valid float, or text. + Use active patterns so the logic reads as a single match expression, and demonstrate it on + "42", "3.14", and "hello" with dotnet fsi. + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "\\(\\|" + expect_tools: ["bash"] + rubric: + - "Defines partial active patterns (|Int|_|) / (|Float|_|) returning Option" + - "The classification is expressed as a single match over those patterns" + - "Demonstrated on the three sample inputs via dotnet fsi" + timeout: 180 + + - name: "FizzBuzz with a parameterized active pattern" + prompt: "Write FizzBuzz for 1..20 in F# using a parameterized active pattern for divisibility, and run it with dotnet fsi." + assertions: + - type: "exit_success" + - type: "output_contains" + value: "FizzBuzz" + expect_tools: ["bash"] + rubric: + - "Uses a parameterized active pattern such as (|DivisibleBy|_|)" + - "Output contains Fizz, Buzz, and FizzBuzz in the right places" + timeout: 150 diff --git a/tests/dotnet-fsharp/fsharp-async-and-tasks/eval.yaml b/tests/dotnet-fsharp/fsharp-async-and-tasks/eval.yaml new file mode 100644 index 0000000000..8f619fe30e --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-async-and-tasks/eval.yaml @@ -0,0 +1,34 @@ +scenarios: + - name: "Remove a blocking .Result call" + prompt: | + This F# blocks a thread and can deadlock: + + let getLength (client: System.Net.Http.HttpClient) url = + client.GetStringAsync(url).Result.Length + + Rewrite it to be properly asynchronous and explain which of async {} or task {} you chose + and why. + assertions: + - type: "output_matches" + pattern: "(task \\{|async \\{)" + - type: "output_not_matches" + pattern: "\\.Result" + rubric: + - "Replaces .Result with a let! inside task {} or async {} (using Async.AwaitTask if async)" + - "Explains the async vs task choice (interop/Task-centric vs composition)" + timeout: 150 + + - name: "Run independent async work in parallel" + prompt: | + In F#, I fetch three independent values, each via an async function. Right now I await them + one after another. Show how to run them in parallel and collect all results. Demonstrate the + shape with dotnet fsi using simple async functions that return after a short delay. + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(Async.Parallel|Task.WhenAll)" + expect_tools: ["bash"] + rubric: + - "Uses Async.Parallel (or Task.WhenAll) instead of sequential awaits" + - "Demonstrated collecting all results via dotnet fsi" + timeout: 180 diff --git a/tests/dotnet-fsharp/fsharp-domain-modeling/eval.yaml b/tests/dotnet-fsharp/fsharp-domain-modeling/eval.yaml new file mode 100644 index 0000000000..9ed14ff3f0 --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-domain-modeling/eval.yaml @@ -0,0 +1,33 @@ +scenarios: + - name: "Constrain a primitive with a smart constructor" + prompt: | + In F#, model an EmailAddress so that an invalid email (no '@') can never be constructed. + A caller must go through validation that returns a Result. Show the type and demonstrate + with dotnet fsi that creating "bad" fails and "a@b.com" succeeds. + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(private)" + - type: "output_matches" + pattern: "(Ok|Error)" + expect_tools: ["bash"] + rubric: + - "Uses a single-case discriminated union with a private constructor" + - "Provides a create function returning Result (Ok/Error), not a throwing constructor" + - "Demonstrated that invalid input is rejected and valid input is accepted via dotnet fsi" + timeout: 180 + + - name: "Replace boolean flags and parallel options with a DU" + prompt: | + This F# record allows illegal states: + + type Account = { Email: string option; VerifiedAt: System.DateTime option } + + Redesign it so that "verified but no email" is impossible to represent. Explain the new type. + assertions: + - type: "output_matches" + pattern: "(type|\\|)" + rubric: + - "Replaces the two parallel option fields with a discriminated union that names the real states" + - "The new model makes 'verified without an email' unrepresentable" + timeout: 150 diff --git a/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml b/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml new file mode 100644 index 0000000000..1adf4ef320 --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml @@ -0,0 +1,30 @@ +scenarios: + - name: "Replace exception control flow with Result" + prompt: | + Refactor this F# so it does not use exceptions for the divide-by-zero case; the caller + should get a Result instead. Demonstrate both the success and failure path with dotnet fsi. + + let divide x y = x / y + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(Ok|Error)" + expect_tools: ["bash"] + rubric: + - "divide returns a Result (Ok value / Error message) rather than throwing" + - "The agent demonstrated both a successful division and the divide-by-zero error via dotnet fsi" + timeout: 180 + + - name: "Accumulate validation errors instead of stopping at the first" + prompt: | + In F#, validate a form with a Name (non-empty) and an Age (between 0 and 130). When BOTH + are invalid, the caller must receive BOTH error messages, not just the first one. Implement + it and demonstrate the both-invalid case with dotnet fsi. + assertions: + - type: "exit_success" + expect_tools: ["bash"] + rubric: + - "Uses applicative validation (and! / a Validation type) so errors accumulate" + - "Does NOT use Result.bind / let! chaining that would short-circuit on the first error" + - "The both-invalid run reports two errors" + timeout: 180 diff --git a/tests/dotnet-fsharp/fsharp-project-organization/eval.yaml b/tests/dotnet-fsharp/fsharp-project-organization/eval.yaml new file mode 100644 index 0000000000..4c0bb3d6f9 --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-project-organization/eval.yaml @@ -0,0 +1,24 @@ +scenarios: + - name: "Diagnose an order-dependent compile error" + prompt: | + My F# project fails to build with "The value or constructor 'Customer' is not defined", + even though the Customer type clearly exists in Domain/Types.fs. In the .fsproj the Compile + items are listed as Workflows/PlaceOrder.fs first, then Domain/Types.fs. What is wrong and + how do I fix it? + assertions: + - type: "output_matches" + pattern: "(order|Compile)" + rubric: + - "Identifies that F# compiles files in .fsproj order with no forward references" + - "Fix is to move Domain/Types.fs above Workflows/PlaceOrder.fs in the .fsproj" + timeout: 150 + + - name: "Choose namespace vs module" + prompt: "In F#, when should the top of a file be 'namespace Foo' versus 'module Foo'? Give a one-line rule and an example of each." + assertions: + - type: "output_matches" + pattern: "(namespace|module)" + rubric: + - "Explains namespace spans files and holds types (functions need an inner module); top-level module is one file and can hold functions directly" + - "Gives a correct small example of each" + timeout: 120 diff --git a/tests/dotnet-fsharp/fsharp-scripts/eval.yaml b/tests/dotnet-fsharp/fsharp-scripts/eval.yaml new file mode 100644 index 0000000000..14cc98360f --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-scripts/eval.yaml @@ -0,0 +1,35 @@ +scenarios: + - name: "Run a quick F# experiment as a script" + prompt: "I want to try out F# list comprehensions quickly - no project. Generate the first 10 triangular numbers and print them." + assertions: + - type: "exit_success" + - type: "output_contains" + value: "55" + - type: "output_not_matches" + pattern: "dotnet new" + expect_tools: ["bash"] + rubric: + - "Creates a .fsx script and runs it with dotnet fsi (not a full project via dotnet new)" + - "Output includes the 10th triangular number, 55" + timeout: 150 + + - name: "Reference a NuGet package from a script" + prompt: "Without creating a project, write an F# script that uses the Humanizer NuGet package to turn the number 1234567 into words, and run it." + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "#r \"nuget:" + - type: "output_not_matches" + pattern: "dotnet add package" + expect_tools: ["bash"] + rubric: + - "Uses #r \"nuget: Humanizer\" inside a .fsx script rather than dotnet add package" + - "Runs the script with dotnet fsi and prints the number in words" + timeout: 180 + + - name: "Does not activate for a full project request" + prompt: "Create a new F# console application project that I can keep building on with dotnet build." + expect_activation: false + rubric: + - "The fsharp-scripts skill does not activate because the user wants a full project, not a throwaway script" + timeout: 120 diff --git a/tests/dotnet-fsharp/fsharp-type-providers/eval.yaml b/tests/dotnet-fsharp/fsharp-type-providers/eval.yaml new file mode 100644 index 0000000000..8ceb871813 --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-type-providers/eval.yaml @@ -0,0 +1,26 @@ +scenarios: + - name: "Read JSON with a type provider" + prompt: | + Without hand-writing record types, give me strongly-typed access to this JSON in F#: + { "city": "Oslo", "tempC": 12.5 } + Then parse { "city": "Bergen", "tempC": 9.0 } and print the city and temperature. Run it + with dotnet fsi. + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(JsonProvider|FSharp.Data)" + - type: "output_contains" + value: "Bergen" + expect_tools: ["bash"] + rubric: + - "Uses FSharp.Data JsonProvider with the first JSON as the sample" + - "Accesses .City and .TempC as typed members, not string keys" + - "Runs via dotnet fsi and prints Bergen and its temperature" + timeout: 240 + + - name: "Does not reach for a provider for trivial parsing" + prompt: "In F#, I just need to read a single integer out of the string \"42\". What's the simplest way?" + expect_activation: false + rubric: + - "Recommends Int32.TryParse / simple parsing rather than a type provider for a single value" + timeout: 120 diff --git a/tests/dotnet-fsharp/fsharp-units-of-measure/eval.yaml b/tests/dotnet-fsharp/fsharp-units-of-measure/eval.yaml new file mode 100644 index 0000000000..e5cd77fdcb --- /dev/null +++ b/tests/dotnet-fsharp/fsharp-units-of-measure/eval.yaml @@ -0,0 +1,27 @@ +scenarios: + - name: "Catch a unit mismatch at compile time" + prompt: | + In F#, use units of measure to model meters and seconds. Compute a speed from a distance + and a time, and show with dotnet fsi that the correct computation works while adding meters + to seconds is rejected by the compiler (show the error or explain it). + assertions: + - type: "output_matches" + pattern: "\\[\\]" + expect_tools: ["bash"] + rubric: + - "Declares [] types for meters and seconds" + - "Computes speed as distance / time yielding float" + - "Demonstrates that adding incompatible units fails to compile" + timeout: 180 + + - name: "Typed conversion function" + prompt: "In F# with units of measure, write a typed function that converts a distance in meters (float) to feet (float). Run a quick check with dotnet fsi." + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "\\[\\]" + expect_tools: ["bash"] + rubric: + - "Conversion function has type float -> float" + - "Uses a conversion constant with the compound unit ft/m" + timeout: 150 diff --git a/tests/dotnet-fsharp/testing-fsharp/eval.yaml b/tests/dotnet-fsharp/testing-fsharp/eval.yaml new file mode 100644 index 0000000000..e05742a14b --- /dev/null +++ b/tests/dotnet-fsharp/testing-fsharp/eval.yaml @@ -0,0 +1,22 @@ +scenarios: + - name: "Add a property-based test for an invariant" + prompt: | + I have an F# function that reverses a list. I want a test proving that reversing twice + returns the original list for any input, not just a couple of examples. Show how, and run it. + assertions: + - type: "output_matches" + pattern: "(FsCheck|testProperty|Check.Quick)" + rubric: + - "Uses property-based testing (FsCheck / Expecto testProperty) for the round-trip invariant" + - "The property is stated over arbitrary lists, not fixed examples" + timeout: 180 + + - name: "Recommend property tests where invariants exist" + prompt: "In F#, what kinds of tests should I write for an encode/decode pair of functions?" + assertions: + - type: "output_matches" + pattern: "(round-trip|property|FsCheck)" + rubric: + - "Recommends a round-trip property test (decode(encode x) = x) with FsCheck" + - "Mentions example-based tests for specific known cases as a complement" + timeout: 120 diff --git a/tests/dotnet-fsharp/writing-idiomatic-fsharp/eval.yaml b/tests/dotnet-fsharp/writing-idiomatic-fsharp/eval.yaml new file mode 100644 index 0000000000..84454f398d --- /dev/null +++ b/tests/dotnet-fsharp/writing-idiomatic-fsharp/eval.yaml @@ -0,0 +1,51 @@ +scenarios: + - name: "Refactor imperative loop to a pipeline" + prompt: | + Here is some F#: + + let sumOfSquares xs = + let mutable total = 0 + for x in xs do + total <- total + x * x + total + + Rewrite it in idiomatic F#, then run it on [1; 2; 3; 4] with dotnet fsi to prove it works. + assertions: + - type: "exit_success" + - type: "output_contains" + value: "30" + - type: "output_matches" + pattern: "\\|>" + - type: "output_not_matches" + pattern: "mutable" + expect_tools: ["bash"] + rubric: + - "The rewrite removes the mutable accumulator and for-loop" + - "The rewrite uses a collection function (List.sumBy / List.map + List.sum) with the pipe operator" + - "The agent ran the code with dotnet fsi and it produced 30" + timeout: 180 + + - name: "Replace null handling with Option" + prompt: | + Make this F# idiomatic - it should not use null. Then demonstrate it with dotnet fsi. + + let displayName name = + if name <> null then name.ToString().ToUpper() else "ANONYMOUS" + assertions: + - type: "exit_success" + - type: "output_matches" + pattern: "(Option|Some|None)" + - type: "output_not_matches" + pattern: "<> null" + expect_tools: ["bash"] + rubric: + - "The rewrite models absence with Option instead of null" + - "The agent verified the behavior by running it with dotnet fsi" + timeout: 180 + + - name: "Does not activate for unrelated language-agnostic request" + prompt: "Write me a quick throwaway script in whatever language to print the first 10 prime numbers." + expect_activation: false + rubric: + - "The writing-idiomatic-fsharp skill does not activate because the request is not about F#" + timeout: 120 From a25fb285d6ad2520e5ce1d4bfd8d07fc79d53b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=CE=BBstor?= Date: Thu, 11 Jun 2026 15:17:00 +0200 Subject: [PATCH 2/3] dotnet-fsharp: address PR review feedback on skill and evals --- plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md | 2 +- tests/dotnet-fsharp/fsharp-error-handling/eval.yaml | 2 ++ tests/dotnet-fsharp/testing-fsharp/eval.yaml | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md index cc4d595aa2..44c3b33dc0 100644 --- a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md +++ b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/SKILL.md @@ -91,7 +91,7 @@ See the `fsharp-scripts` skill for the `.fsx` workflow. | `let mutable sum = 0` + `for x in xs do sum <- sum + x` | `xs \|> List.sum` | | `let mutable acc = []` + loop with `acc <- f x :: acc` | `xs \|> List.map f` | | `if x <> null then f x else d` | `x \|> Option.map f \|> Option.defaultValue d` | -| `let r = if c then a else b` (statement style) | `let r = if c then a else b` (used as a value, single expression) | +| `let mutable r = 0` + `if c then r <- a else r <- b` | `let r = if c then a else b` | | `g(f(x))` | `x \|> f \|> g` | | nested `if/else if` over a closed set | `match value with ...` | diff --git a/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml b/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml index 1adf4ef320..4dad496ce2 100644 --- a/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml +++ b/tests/dotnet-fsharp/fsharp-error-handling/eval.yaml @@ -22,6 +22,8 @@ scenarios: it and demonstrate the both-invalid case with dotnet fsi. assertions: - type: "exit_success" + - type: "output_matches" + pattern: "(?s)(?=.*Name)(?=.*Age)" expect_tools: ["bash"] rubric: - "Uses applicative validation (and! / a Validation type) so errors accumulate" diff --git a/tests/dotnet-fsharp/testing-fsharp/eval.yaml b/tests/dotnet-fsharp/testing-fsharp/eval.yaml index e05742a14b..08003b8c14 100644 --- a/tests/dotnet-fsharp/testing-fsharp/eval.yaml +++ b/tests/dotnet-fsharp/testing-fsharp/eval.yaml @@ -4,8 +4,10 @@ scenarios: I have an F# function that reverses a list. I want a test proving that reversing twice returns the original list for any input, not just a couple of examples. Show how, and run it. assertions: + - type: "exit_success" - type: "output_matches" pattern: "(FsCheck|testProperty|Check.Quick)" + expect_tools: ["bash"] rubric: - "Uses property-based testing (FsCheck / Expecto testProperty) for the round-trip invariant" - "The property is stated over arbitrary lists, not fixed examples" From 496005c157046334836afabf6fa17be16a761ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=CE=BBstor?= Date: Thu, 11 Jun 2026 15:23:56 +0200 Subject: [PATCH 3/3] dotnet-fsharp: attribute FsToolkit constructs, soften compile claim --- plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md | 3 +++ .../writing-idiomatic-fsharp/references/csharpism-rewrites.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md b/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md index 727229c8ad..ba3adfe1df 100644 --- a/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md +++ b/plugins/dotnet-fsharp/skills/fsharp-error-handling/SKILL.md @@ -92,6 +92,7 @@ let validateForm input = ``` `and!` (not `let!`) is what makes the validators independent and accumulating. +`validation { }` and the `Validation` type come from FsToolkit.ErrorHandling, not FSharp.Core. ### 5. Flip a list of Results: sequence / traverse @@ -104,6 +105,8 @@ let parseAll lines = |> List.sequenceResultM // -> Result ``` +`List.sequenceResultM` (and `traverseResultM`) come from FsToolkit.ErrorHandling, not FSharp.Core. + ### 6. Convert exceptions at the boundary When calling a .NET API that throws, catch once and convert to `Result`: diff --git a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md index b89a075ad4..5205616531 100644 --- a/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md +++ b/plugins/dotnet-fsharp/skills/writing-idiomatic-fsharp/references/csharpism-rewrites.md @@ -1,6 +1,8 @@ # C#-ism to idiomatic F# rewrites -A before/after catalog for the most common ways C# habits leak into F#. Each pair compiles. +A before/after catalog for the most common ways C# habits leak into F#. Each pair is a minimal +fragment focused on the rewrite; some reference identifiers (e.g. `numbers`, `httpClient`) are +assumed already in scope. ## 1. Loop + mutable accumulator to a collection function