Skip to content

Commit 40f268c

Browse files
authored
.md skill added to get fsharp-diagnostics (like IDE does, via FsharpChecker) (#19263)
1 parent cb4bfe4 commit 40f268c

File tree

9 files changed

+639
-0
lines changed

9 files changed

+639
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
name: fsharp-diagnostics
3+
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."
4+
---
5+
6+
# F# Diagnostics
7+
8+
**Scope:** `src/Compiler/` files only (`FSharp.Compiler.Service.fsproj`, Release, net10.0).
9+
10+
## Setup (run once per shell session)
11+
12+
```bash
13+
GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; }
14+
```
15+
16+
## Parse first, typecheck second
17+
18+
```bash
19+
GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs
20+
```
21+
If errors → fix syntax. Do NOT typecheck until parse is clean.
22+
```bash
23+
GetErrors src/Compiler/Checking/CheckBasics.fs
24+
```
25+
26+
## Find references for a single symbol (line 1-based, col 0-based)
27+
28+
Before renaming or to understand call sites:
29+
```bash
30+
GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5
31+
```
32+
33+
## Type hints for a range selection (begin and end line numbers, 1-based)
34+
35+
To see inferred types as inline `// (name: Type)` comments:
36+
```bash
37+
GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032
38+
```
39+
40+
## Other
41+
42+
```bash
43+
GetErrors --check-project # typecheck entire project
44+
GetErrors --ping
45+
GetErrors --shutdown
46+
```
47+
48+
First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# get-fsharp-errors.sh — minimal passthrough client for fsharp-diag-server
5+
# Usage:
6+
# get-fsharp-errors.sh [--parse-only] <file.fs>
7+
# get-fsharp-errors.sh --check-project <project.fsproj>
8+
# get-fsharp-errors.sh --ping
9+
# get-fsharp-errors.sh --shutdown
10+
11+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12+
SERVER_PROJECT="$(cd "$SCRIPT_DIR/../server" && pwd)"
13+
SOCK_DIR="$HOME/.fsharp-diag"
14+
15+
get_repo_root() {
16+
git rev-parse --show-toplevel 2>/dev/null || pwd
17+
}
18+
19+
get_socket_path() {
20+
local root="$1"
21+
local hash
22+
hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16)
23+
echo "$SOCK_DIR/${hash}.sock"
24+
}
25+
26+
ensure_server() {
27+
local root="$1"
28+
local sock="$2"
29+
30+
# Check if socket exists and server responds to ping
31+
if [ -S "$sock" ]; then
32+
local pong
33+
pong=$(printf '{"command":"ping"}\n' | nc -U "$sock" 2>/dev/null || true)
34+
if echo "$pong" | grep -q '"ok"'; then
35+
return 0
36+
fi
37+
# Stale socket
38+
rm -f "$sock"
39+
fi
40+
41+
# Start server
42+
mkdir -p "$SOCK_DIR"
43+
local log_hash
44+
log_hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16)
45+
local log_file="$SOCK_DIR/${log_hash}.log"
46+
47+
nohup dotnet run -c Release --project "$SERVER_PROJECT" -- --repo-root "$root" > "$log_file" 2>&1 &
48+
49+
# Wait for socket to appear (max 60s)
50+
local waited=0
51+
while [ ! -S "$sock" ] && [ $waited -lt 60 ]; do
52+
sleep 1
53+
waited=$((waited + 1))
54+
done
55+
56+
if [ ! -S "$sock" ]; then
57+
echo '{"error":"Server failed to start within 60s. Check log: '"$log_file"'"}' >&2
58+
exit 1
59+
fi
60+
}
61+
62+
send_request() {
63+
local sock="$1"
64+
local request="$2"
65+
printf '%s\n' "$request" | nc -U "$sock"
66+
}
67+
68+
# --- Main ---
69+
70+
REPO_ROOT=$(get_repo_root)
71+
SOCK_PATH=$(get_socket_path "$REPO_ROOT")
72+
73+
case "${1:-}" in
74+
--ping)
75+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
76+
send_request "$SOCK_PATH" '{"command":"ping"}'
77+
;;
78+
--shutdown)
79+
send_request "$SOCK_PATH" '{"command":"shutdown"}'
80+
;;
81+
--parse-only)
82+
shift
83+
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
84+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
85+
send_request "$SOCK_PATH" "{\"command\":\"parseOnly\",\"file\":\"$FILE\"}"
86+
;;
87+
--check-project)
88+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
89+
send_request "$SOCK_PATH" '{"command":"checkProject"}'
90+
;;
91+
--find-refs)
92+
shift
93+
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
94+
LINE="$2"
95+
COL="$3"
96+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
97+
send_request "$SOCK_PATH" "{\"command\":\"findRefs\",\"file\":\"$FILE\",\"line\":$LINE,\"col\":$COL}"
98+
;;
99+
--type-hints)
100+
shift
101+
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
102+
START_LINE="$2"
103+
END_LINE="$3"
104+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
105+
send_request "$SOCK_PATH" "{\"command\":\"typeHints\",\"file\":\"$FILE\",\"startLine\":$START_LINE,\"endLine\":$END_LINE}"
106+
;;
107+
-*)
108+
echo "Usage: get-fsharp-errors [--parse-only] <file.fs>" >&2
109+
echo " get-fsharp-errors --check-project <project.fsproj>" >&2
110+
echo " get-fsharp-errors --ping | --shutdown" >&2
111+
exit 1
112+
;;
113+
*)
114+
FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
115+
ensure_server "$REPO_ROOT" "$SOCK_PATH"
116+
send_request "$SOCK_PATH" "{\"command\":\"check\",\"file\":\"$FILE\"}"
117+
;;
118+
esac
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
module FSharpDiagServer.DesignTimeBuild
2+
3+
open System
4+
open System.Diagnostics
5+
open System.IO
6+
open System.Text.Json
7+
8+
type DtbResult =
9+
{ CompilerArgs: string array }
10+
11+
type DtbConfig =
12+
{ TargetFramework: string option
13+
Configuration: string }
14+
15+
let defaultConfig =
16+
{ TargetFramework = Some "net10.0"
17+
Configuration = "Release" }
18+
19+
let run (fsprojPath: string) (config: DtbConfig) =
20+
async {
21+
let tfmArg =
22+
config.TargetFramework
23+
|> Option.map (fun tfm -> $" /p:TargetFramework={tfm}")
24+
|> Option.defaultValue ""
25+
26+
let projDir = Path.GetDirectoryName(fsprojPath)
27+
28+
// /t:Build runs BeforeBuild (generates buildproperties.fs via CompileBefore).
29+
// DesignTimeBuild=true skips dependency projects.
30+
// SkipCompilerExecution=true + ProvideCommandLineArgs=true populates FscCommandLineArgs without compiling.
31+
let psi =
32+
ProcessStartInfo(
33+
FileName = "dotnet",
34+
Arguments =
35+
$"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",
36+
RedirectStandardOutput = true,
37+
RedirectStandardError = true,
38+
UseShellExecute = false,
39+
WorkingDirectory = projDir
40+
)
41+
42+
use proc = Process.Start(psi)
43+
let! stdout = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask
44+
let! stderr = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask
45+
do! proc.WaitForExitAsync() |> Async.AwaitTask
46+
47+
if proc.ExitCode <> 0 then
48+
return Error $"DTB failed (exit {proc.ExitCode}): {stderr}"
49+
else
50+
try
51+
// MSBuild may emit warnings before the JSON; find the JSON start
52+
let jsonStart = stdout.IndexOf('{')
53+
if jsonStart < 0 then
54+
return Error $"No JSON in DTB output: {stdout.[..200]}"
55+
else
56+
let doc = JsonDocument.Parse(stdout.Substring(jsonStart))
57+
let items = doc.RootElement.GetProperty("Items")
58+
59+
let args =
60+
items.GetProperty("FscCommandLineArgs").EnumerateArray()
61+
|> Seq.map (fun e -> e.GetProperty("Identity").GetString())
62+
|> Seq.toArray
63+
64+
return Ok { CompilerArgs = args }
65+
with ex ->
66+
return Error $"Failed to parse DTB output: {ex.Message}"
67+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module FSharpDiagServer.DiagnosticsFormatter
2+
3+
open FSharp.Compiler.Diagnostics
4+
5+
let private formatOne (getLines: string -> string[]) (d: FSharpDiagnostic) =
6+
let sev = match d.Severity with FSharpDiagnosticSeverity.Error -> "ERROR" | _ -> "WARNING"
7+
let lines = getLines d.Range.FileName
8+
let src = if d.StartLine >= 1 && d.StartLine <= lines.Length then $" | {lines.[d.StartLine - 1].Trim()}" else ""
9+
$"{sev} {d.ErrorNumberText} ({d.StartLine},{d.Start.Column}-{d.EndLine},{d.End.Column}) {d.Message.Replace('\n', ' ').Replace('\r', ' ')}{src}"
10+
11+
let private withLineReader f =
12+
let cache = System.Collections.Generic.Dictionary<string, string[]>()
13+
let getLines path =
14+
match cache.TryGetValue(path) with
15+
| true, l -> l
16+
| _ -> let l = try System.IO.File.ReadAllLines(path) with _ -> [||] in cache.[path] <- l; l
17+
f getLines
18+
19+
let private relevant (diags: FSharpDiagnostic array) =
20+
diags |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error || d.Severity = FSharpDiagnosticSeverity.Warning)
21+
22+
let formatFile (diags: FSharpDiagnostic array) =
23+
let diags = relevant diags
24+
if diags.Length = 0 then "OK"
25+
else withLineReader (fun getLines -> diags |> Array.map (formatOne getLines) |> String.concat "\n")
26+
27+
let formatProject (repoRoot: string) (diags: FSharpDiagnostic array) =
28+
let diags = relevant diags
29+
if diags.Length = 0 then "OK"
30+
else
31+
let root = repoRoot.TrimEnd('/') + "/"
32+
let rel (path: string) = if path.StartsWith(root) then path.Substring(root.Length) else path
33+
withLineReader (fun getLines ->
34+
diags
35+
|> Array.groupBy (fun d -> d.Range.FileName)
36+
|> Array.collect (fun (f, ds) -> Array.append [| $"--- {rel f}" |] (ds |> Array.map (formatOne getLines)))
37+
|> String.concat "\n")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project>
2+
<!-- Stops repo-level Directory.Build.props traversal (this file is the first found).
3+
Also blocks Directory.Build.targets import. -->
4+
<PropertyGroup>
5+
<ImportDirectoryBuildTargets>false</ImportDirectoryBuildTargets>
6+
<BaseOutputPath>$(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/bin/</BaseOutputPath>
7+
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/obj/</BaseIntermediateOutputPath>
8+
</PropertyGroup>
9+
</Project>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="FSharp.Compiler.Service" Version="43.*" />
10+
<PackageReference Update="FSharp.Core" Version="10.*" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<Compile Include="DesignTimeBuild.fs" />
15+
<Compile Include="ProjectManager.fs" />
16+
<Compile Include="DiagnosticsFormatter.fs" />
17+
<Compile Include="Server.fs" />
18+
<Compile Include="Program.fs" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
module FSharpDiagServer.Program
2+
3+
open System
4+
5+
[<EntryPoint>]
6+
let main argv =
7+
let mutable repoRoot = Environment.CurrentDirectory
8+
9+
let mutable i = 0
10+
11+
while i < argv.Length do
12+
match argv.[i] with
13+
| "--repo-root" when i + 1 < argv.Length ->
14+
repoRoot <- argv.[i + 1]
15+
i <- i + 2
16+
| other ->
17+
eprintfn $"Unknown argument: {other}"
18+
i <- i + 1
19+
20+
// Resolve to absolute path
21+
repoRoot <- IO.Path.GetFullPath(repoRoot)
22+
23+
let config: Server.ServerConfig =
24+
{ RepoRoot = repoRoot
25+
IdleTimeoutMinutes = 240.0 }
26+
27+
eprintfn $"[fsharp-diag] Starting server for {repoRoot}"
28+
eprintfn $"[fsharp-diag] Socket: {Server.deriveSocketPath repoRoot}"
29+
30+
Server.startServer config |> Async.RunSynchronously
31+
0
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
module FSharpDiagServer.ProjectManager
2+
3+
open System.IO
4+
open FSharp.Compiler.CodeAnalysis
5+
6+
type ProjectManager(checker: FSharpChecker) =
7+
let mutable cached: (System.DateTime * FSharpProjectOptions) option = None
8+
let gate = obj ()
9+
10+
let isSourceFile (s: string) =
11+
not (s.StartsWith("-"))
12+
&& (s.EndsWith(".fs", System.StringComparison.OrdinalIgnoreCase)
13+
|| s.EndsWith(".fsi", System.StringComparison.OrdinalIgnoreCase))
14+
15+
member _.ResolveProjectOptions(fsprojPath: string) =
16+
async {
17+
let fsprojMtime = File.GetLastWriteTimeUtc(fsprojPath)
18+
let current =
19+
lock gate (fun () ->
20+
match cached with
21+
| Some(mtime, opts) when mtime = fsprojMtime -> Some opts
22+
| Some _ -> cached <- None; None
23+
| None -> None)
24+
25+
match current with
26+
| Some opts -> return Ok opts
27+
| None ->
28+
let! dtbResult = DesignTimeBuild.run fsprojPath DesignTimeBuild.defaultConfig
29+
30+
match dtbResult with
31+
| Error msg -> return Error msg
32+
| Ok dtb ->
33+
let projDir = Path.GetDirectoryName(fsprojPath)
34+
35+
let resolve (s: string) =
36+
if Path.IsPathRooted(s) then s else Path.GetFullPath(Path.Combine(projDir, s))
37+
38+
let resolvedArgs =
39+
dtb.CompilerArgs
40+
|> Array.map (fun a -> if isSourceFile a then resolve a else a)
41+
42+
let sourceFiles = resolvedArgs |> Array.filter isSourceFile
43+
let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile)
44+
let opts = checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, flagsOnly)
45+
let options = { opts with SourceFiles = sourceFiles }
46+
lock gate (fun () -> cached <- Some(fsprojMtime, options))
47+
return Ok options
48+
}
49+
50+
member _.Invalidate() = lock gate (fun () -> cached <- None)

0 commit comments

Comments
 (0)