Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/skills/fsharp-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
name: fsharp-diagnostics
description: "After modifying any F# file, use this to get quick parse errors and typecheck warnings+errors. Also finds symbol references and inferred type hints."
---

# F# Diagnostics

**Scope:** `src/Compiler/` files only (`FSharp.Compiler.Service.fsproj`, Release, net10.0).

## Setup (run once per shell session)

```bash
GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; }
```

## Parse first, typecheck second

```bash
GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs
```
If errors → fix syntax. Do NOT typecheck until parse is clean.
```bash
GetErrors src/Compiler/Checking/CheckBasics.fs
```

## Find references for a single symbol (line 1-based, col 0-based)

Before renaming or to understand call sites:
```bash
GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5
```

## Type hints for a range selection (begin and end line numbers, 1-based)

To see inferred types as inline `// (name: Type)` comments:
```bash
GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032
```

## Other

```bash
GetErrors --check-project # typecheck entire project
GetErrors --ping
GetErrors --shutdown
```

First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM.
118 changes: 118 additions & 0 deletions .github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail

# get-fsharp-errors.sh — minimal passthrough client for fsharp-diag-server
# Usage:
# get-fsharp-errors.sh [--parse-only] <file.fs>
# get-fsharp-errors.sh --check-project <project.fsproj>
# get-fsharp-errors.sh --ping
# get-fsharp-errors.sh --shutdown

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVER_PROJECT="$(cd "$SCRIPT_DIR/../server" && pwd)"
SOCK_DIR="$HOME/.fsharp-diag"

get_repo_root() {
git rev-parse --show-toplevel 2>/dev/null || pwd
}

get_socket_path() {
local root="$1"
local hash
hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16)
echo "$SOCK_DIR/${hash}.sock"
}

ensure_server() {
local root="$1"
local sock="$2"

# Check if socket exists and server responds to ping
if [ -S "$sock" ]; then
local pong
pong=$(printf '{"command":"ping"}\n' | nc -U "$sock" 2>/dev/null || true)
if echo "$pong" | grep -q '"ok"'; then
return 0
fi
# Stale socket
rm -f "$sock"
fi

# Start server
mkdir -p "$SOCK_DIR"
local log_hash
log_hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16)
local log_file="$SOCK_DIR/${log_hash}.log"

nohup dotnet run -c Release --project "$SERVER_PROJECT" -- --repo-root "$root" > "$log_file" 2>&1 &

# Wait for socket to appear (max 60s)
local waited=0
while [ ! -S "$sock" ] && [ $waited -lt 60 ]; do
sleep 1
waited=$((waited + 1))
done

if [ ! -S "$sock" ]; then
echo '{"error":"Server failed to start within 60s. Check log: '"$log_file"'"}' >&2
exit 1
fi
}

send_request() {
local sock="$1"
local request="$2"
printf '%s\n' "$request" | nc -U "$sock"
}

# --- Main ---

REPO_ROOT=$(get_repo_root)
SOCK_PATH=$(get_socket_path "$REPO_ROOT")

case "${1:-}" in
--ping)
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" '{"command":"ping"}'
;;
--shutdown)
send_request "$SOCK_PATH" '{"command":"shutdown"}'
;;
--parse-only)
shift
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" "{\"command\":\"parseOnly\",\"file\":\"$FILE\"}"
;;
--check-project)
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" '{"command":"checkProject"}'
;;
--find-refs)
shift
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
LINE="$2"
COL="$3"
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" "{\"command\":\"findRefs\",\"file\":\"$FILE\",\"line\":$LINE,\"col\":$COL}"
;;
--type-hints)
shift
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
START_LINE="$2"
END_LINE="$3"
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" "{\"command\":\"typeHints\",\"file\":\"$FILE\",\"startLine\":$START_LINE,\"endLine\":$END_LINE}"
;;
-*)
echo "Usage: get-fsharp-errors [--parse-only] <file.fs>" >&2
echo " get-fsharp-errors --check-project <project.fsproj>" >&2
echo " get-fsharp-errors --ping | --shutdown" >&2
exit 1
;;
*)
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
ensure_server "$REPO_ROOT" "$SOCK_PATH"
send_request "$SOCK_PATH" "{\"command\":\"check\",\"file\":\"$FILE\"}"
;;
esac
67 changes: 67 additions & 0 deletions .github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module FSharpDiagServer.DesignTimeBuild

open System
open System.Diagnostics
open System.IO
open System.Text.Json

type DtbResult =
{ CompilerArgs: string array }

type DtbConfig =
{ TargetFramework: string option
Configuration: string }

let defaultConfig =
{ TargetFramework = Some "net10.0"
Configuration = "Release" }

let run (fsprojPath: string) (config: DtbConfig) =
async {
let tfmArg =
config.TargetFramework
|> Option.map (fun tfm -> $" /p:TargetFramework={tfm}")
|> Option.defaultValue ""

let projDir = Path.GetDirectoryName(fsprojPath)

// /t:Build runs BeforeBuild (generates buildproperties.fs via CompileBefore).
// DesignTimeBuild=true skips dependency projects.
// SkipCompilerExecution=true + ProvideCommandLineArgs=true populates FscCommandLineArgs without compiling.
let psi =
ProcessStartInfo(
FileName = "dotnet",
Arguments =
$"msbuild \"{fsprojPath}\" /t:Build /p:DesignTimeBuild=true /p:SkipCompilerExecution=true /p:ProvideCommandLineArgs=true /p:CopyBuildOutputToOutputDirectory=false /p:CopyOutputSymbolsToOutputDirectory=false /p:BUILDING_USING_DOTNET=true /p:Configuration={config.Configuration}{tfmArg} /nologo /v:q /getItem:FscCommandLineArgs",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
WorkingDirectory = projDir
)

use proc = Process.Start(psi)
let! stdout = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask
let! stderr = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask
do! proc.WaitForExitAsync() |> Async.AwaitTask

if proc.ExitCode <> 0 then
return Error $"DTB failed (exit {proc.ExitCode}): {stderr}"
else
try
// MSBuild may emit warnings before the JSON; find the JSON start
let jsonStart = stdout.IndexOf('{')
if jsonStart < 0 then
return Error $"No JSON in DTB output: {stdout.[..200]}"
else
let doc = JsonDocument.Parse(stdout.Substring(jsonStart))
let items = doc.RootElement.GetProperty("Items")

let args =
items.GetProperty("FscCommandLineArgs").EnumerateArray()
|> Seq.map (fun e -> e.GetProperty("Identity").GetString())
|> Seq.toArray

return Ok { CompilerArgs = args }
with ex ->
return Error $"Failed to parse DTB output: {ex.Message}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module FSharpDiagServer.DiagnosticsFormatter

open FSharp.Compiler.Diagnostics

let private formatOne (getLines: string -> string[]) (d: FSharpDiagnostic) =
let sev = match d.Severity with FSharpDiagnosticSeverity.Error -> "ERROR" | _ -> "WARNING"
let lines = getLines d.Range.FileName
let src = if d.StartLine >= 1 && d.StartLine <= lines.Length then $" | {lines.[d.StartLine - 1].Trim()}" else ""
$"{sev} {d.ErrorNumberText} ({d.StartLine},{d.Start.Column}-{d.EndLine},{d.End.Column}) {d.Message.Replace('\n', ' ').Replace('\r', ' ')}{src}"

let private withLineReader f =
let cache = System.Collections.Generic.Dictionary<string, string[]>()
let getLines path =
match cache.TryGetValue(path) with
| true, l -> l
| _ -> let l = try System.IO.File.ReadAllLines(path) with _ -> [||] in cache.[path] <- l; l
f getLines

let private relevant (diags: FSharpDiagnostic array) =
diags |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error || d.Severity = FSharpDiagnosticSeverity.Warning)

let formatFile (diags: FSharpDiagnostic array) =
let diags = relevant diags
if diags.Length = 0 then "OK"
else withLineReader (fun getLines -> diags |> Array.map (formatOne getLines) |> String.concat "\n")

let formatProject (repoRoot: string) (diags: FSharpDiagnostic array) =
let diags = relevant diags
if diags.Length = 0 then "OK"
else
let root = repoRoot.TrimEnd('/') + "/"
let rel (path: string) = if path.StartsWith(root) then path.Substring(root.Length) else path
withLineReader (fun getLines ->
diags
|> Array.groupBy (fun d -> d.Range.FileName)
|> Array.collect (fun (f, ds) -> Array.append [| $"--- {rel f}" |] (ds |> Array.map (formatOne getLines)))
|> String.concat "\n")
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project>
<!-- Stops repo-level Directory.Build.props traversal (this file is the first found).
Also blocks Directory.Build.targets import. -->
<PropertyGroup>
<ImportDirectoryBuildTargets>false</ImportDirectoryBuildTargets>
<BaseOutputPath>$(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/bin/</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/obj/</BaseIntermediateOutputPath>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FSharp.Compiler.Service" Version="43.*" />
<PackageReference Update="FSharp.Core" Version="10.*" />
</ItemGroup>

<ItemGroup>
<Compile Include="DesignTimeBuild.fs" />
<Compile Include="ProjectManager.fs" />
<Compile Include="DiagnosticsFormatter.fs" />
<Compile Include="Server.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

</Project>
31 changes: 31 additions & 0 deletions .github/skills/fsharp-diagnostics/server/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module FSharpDiagServer.Program

open System

[<EntryPoint>]
let main argv =
let mutable repoRoot = Environment.CurrentDirectory

let mutable i = 0

while i < argv.Length do
match argv.[i] with
| "--repo-root" when i + 1 < argv.Length ->
repoRoot <- argv.[i + 1]
i <- i + 2
| other ->
eprintfn $"Unknown argument: {other}"
i <- i + 1

// Resolve to absolute path
repoRoot <- IO.Path.GetFullPath(repoRoot)

let config: Server.ServerConfig =
{ RepoRoot = repoRoot
IdleTimeoutMinutes = 240.0 }

eprintfn $"[fsharp-diag] Starting server for {repoRoot}"
eprintfn $"[fsharp-diag] Socket: {Server.deriveSocketPath repoRoot}"

Server.startServer config |> Async.RunSynchronously
0
50 changes: 50 additions & 0 deletions .github/skills/fsharp-diagnostics/server/ProjectManager.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module FSharpDiagServer.ProjectManager

open System.IO
open FSharp.Compiler.CodeAnalysis

type ProjectManager(checker: FSharpChecker) =
let mutable cached: (System.DateTime * FSharpProjectOptions) option = None
let gate = obj ()

let isSourceFile (s: string) =
not (s.StartsWith("-"))
&& (s.EndsWith(".fs", System.StringComparison.OrdinalIgnoreCase)
|| s.EndsWith(".fsi", System.StringComparison.OrdinalIgnoreCase))

member _.ResolveProjectOptions(fsprojPath: string) =
async {
let fsprojMtime = File.GetLastWriteTimeUtc(fsprojPath)
let current =
lock gate (fun () ->
match cached with
| Some(mtime, opts) when mtime = fsprojMtime -> Some opts
| Some _ -> cached <- None; None
| None -> None)

match current with
| Some opts -> return Ok opts
| None ->
let! dtbResult = DesignTimeBuild.run fsprojPath DesignTimeBuild.defaultConfig

match dtbResult with
| Error msg -> return Error msg
| Ok dtb ->
let projDir = Path.GetDirectoryName(fsprojPath)

let resolve (s: string) =
if Path.IsPathRooted(s) then s else Path.GetFullPath(Path.Combine(projDir, s))

let resolvedArgs =
dtb.CompilerArgs
|> Array.map (fun a -> if isSourceFile a then resolve a else a)

let sourceFiles = resolvedArgs |> Array.filter isSourceFile
let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile)
let opts = checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, flagsOnly)
let options = { opts with SourceFiles = sourceFiles }
lock gate (fun () -> cached <- Some(fsprojMtime, options))
return Ok options
}

member _.Invalidate() = lock gate (fun () -> cached <- None)
Loading