diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md new file mode 100644 index 00000000000..76b1b2808c2 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -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. diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh new file mode 100755 index 00000000000..824c37f7628 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -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] +# get-fsharp-errors.sh --check-project +# 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] " >&2 + echo " get-fsharp-errors --check-project " >&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 diff --git a/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs new file mode 100644 index 00000000000..0baf15e1b71 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs @@ -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}" + } diff --git a/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs b/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs new file mode 100644 index 00000000000..e0806fc6682 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs @@ -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() + 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") diff --git a/.github/skills/fsharp-diagnostics/server/Directory.Build.props b/.github/skills/fsharp-diagnostics/server/Directory.Build.props new file mode 100644 index 00000000000..5a08e96c89f --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Directory.Build.props @@ -0,0 +1,9 @@ + + + + false + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/bin/ + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/obj/ + + diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj new file mode 100644 index 00000000000..7f2b01885fa --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + + + + + + + + + + + + + + + diff --git a/.github/skills/fsharp-diagnostics/server/Program.fs b/.github/skills/fsharp-diagnostics/server/Program.fs new file mode 100644 index 00000000000..44e68c070d7 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Program.fs @@ -0,0 +1,31 @@ +module FSharpDiagServer.Program + +open System + +[] +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 diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs new file mode 100644 index 00000000000..f1a955716bf --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -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) diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs new file mode 100644 index 00000000000..9b800d0b359 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -0,0 +1,258 @@ +module FSharpDiagServer.Server + +open System +open System.IO +open System.Net.Sockets +open System.Security.Cryptography +open System.Text +open System.Text.Json +open System.Threading +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open FSharp.Compiler.Text + +let private sockDir = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fsharp-diag") + +let private deriveHash (repoRoot: string) = + SHA256.HashData(Encoding.UTF8.GetBytes(repoRoot)) + |> Convert.ToHexString + |> fun s -> s.Substring(0, 16).ToLowerInvariant() + +let deriveSocketPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.sock") +let deriveMetaPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.meta.json") +let deriveLogPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.log") + +type ServerConfig = { RepoRoot: string; IdleTimeoutMinutes: float } + +let startServer (config: ServerConfig) = + async { + let socketPath = deriveSocketPath config.RepoRoot + let metaPath = deriveMetaPath config.RepoRoot + let fsproj = Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") + Directory.CreateDirectory(sockDir) |> ignore + if File.Exists(socketPath) then File.Delete(socketPath) + + let checker = FSharpChecker.Create(projectCacheSize = 3, useTransparentCompiler = true) + let projectMgr = ProjectManager.ProjectManager(checker) + let mutable lastActivity = DateTimeOffset.UtcNow + let cts = new CancellationTokenSource() + + let getOptions () = projectMgr.ResolveProjectOptions(fsproj) + + let handleRequest (json: string) = + async { + lastActivity <- DateTimeOffset.UtcNow + try + let doc = JsonDocument.Parse(json) + let command = doc.RootElement.GetProperty("command").GetString() + + match command with + | "ping" -> + return $"""{{ "status":"ok", "pid":{Environment.ProcessId} }}""" + + | "parseOnly" -> + let file = doc.RootElement.GetProperty("file").GetString() + if not (File.Exists file) then + return $"""{{ "error":"file not found: {file}" }}""" + else + let sourceText = SourceText.ofString (File.ReadAllText(file)) + // Use project options for correct --langversion, --define etc + let! optionsResult = getOptions () + let parsingArgs = + match optionsResult with + | Ok o -> o.OtherOptions |> Array.toList + | _ -> [] + let parsingOpts, _ = checker.GetParsingOptionsFromCommandLineArgs(file :: parsingArgs) + let! parseResults = checker.ParseFile(file, sourceText, parsingOpts) + return DiagnosticsFormatter.formatFile parseResults.Diagnostics + + | "check" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + if not (File.Exists file) then + return $"""{{ "error":"file not found: {file}" }}""" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> + return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! parseResults, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + let diags = + match checkAnswer with + | FSharpCheckFileAnswer.Succeeded r -> Array.append parseResults.Diagnostics r.Diagnostics + | FSharpCheckFileAnswer.Aborted -> parseResults.Diagnostics + |> Array.distinctBy (fun d -> d.StartLine, d.Start.Column, d.ErrorNumberText) + return DiagnosticsFormatter.formatFile diags + + | "checkProject" -> + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> + return $"ERROR: {msg}" + | Ok options -> + let! results = checker.ParseAndCheckProject(options) + return DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics + + | "findRefs" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + let line = doc.RootElement.GetProperty("line").GetInt32() + let col = doc.RootElement.GetProperty("col").GetInt32() + if not (File.Exists file) then + return $"ERROR: file not found: {file}" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! _, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + match checkAnswer with + | FSharpCheckFileAnswer.Aborted -> return "ERROR: check aborted" + | FSharpCheckFileAnswer.Succeeded checkResults -> + let sourceLines = File.ReadAllLines file + let lineText = sourceLines.[line - 1] + let isIdChar c = Char.IsLetterOrDigit(c) || c = '_' || c = '\'' + let mutable endCol = col + while endCol < lineText.Length && isIdChar lineText.[endCol] do endCol <- endCol + 1 + let mutable startCol = col + while startCol > 0 && isIdChar lineText.[startCol - 1] do startCol <- startCol - 1 + let name = lineText.[startCol..endCol - 1] + if name.Length = 0 then + return "ERROR: no identifier at that position" + else + match checkResults.GetSymbolUseAtLocation(line, endCol, lineText, [name]) with + | None -> return $"ERROR: no symbol found for '{name}' at {line}:{col}" + | Some symbolUse -> + let! projectResults = checker.ParseAndCheckProject(options) + // Collect related symbols: for DU types, also search union cases + let targetNames = ResizeArray() + targetNames.Add(symbolUse.Symbol.FullName) + match symbolUse.Symbol with + | :? FSharpEntity as ent when ent.IsFSharpUnion -> + for uc in ent.UnionCases do targetNames.Add(uc.FullName) + | _ -> () + let uses = + projectResults.GetAllUsesOfAllSymbols() + |> Array.filter (fun u -> targetNames.Contains(u.Symbol.FullName)) + let root = config.RepoRoot.TrimEnd('/') + "/" + let rel (p: string) = if p.StartsWith(root) then p.Substring(root.Length) else p + let lines = + uses |> Array.map (fun u -> + let kind = if u.IsFromDefinition then "DEF" elif u.IsFromType then "TYPE" else "USE" + $"{kind} {rel u.Range.FileName}:{u.Range.StartLine},{u.Range.StartColumn}") + |> Array.distinct + let sym = symbolUse.Symbol + let header = $"Symbol: {sym.DisplayName} ({sym.GetType().Name}) — {lines.Length} references" + return header + "\n" + (lines |> String.concat "\n") + + | "typeHints" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + let startLine = doc.RootElement.GetProperty("startLine").GetInt32() + let endLine = doc.RootElement.GetProperty("endLine").GetInt32() + if not (File.Exists file) then + return $"ERROR: file not found: {file}" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! _, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + match checkAnswer with + | FSharpCheckFileAnswer.Aborted -> return "ERROR: check aborted" + | FSharpCheckFileAnswer.Succeeded checkResults -> + let allSymbols = checkResults.GetAllUsesOfAllSymbolsInFile() + let sourceLines = File.ReadAllLines(file) + // Collect type annotations per line: (name: Type) + let annotations = System.Collections.Generic.Dictionary>() + let addHint line hint = + if not (annotations.ContainsKey line) then annotations.[line] <- ResizeArray() + annotations.[line].Add(hint) + let tagsToStr (tags: FSharp.Compiler.Text.TaggedText[]) = + tags |> Array.map (fun t -> t.Text) |> String.concat "" + for su in allSymbols do + let r = su.Range + if r.StartLine >= startLine && r.StartLine <= endLine && su.IsFromDefinition then + match su.Symbol with + | :? FSharpMemberOrFunctionOrValue as mfv -> + match mfv.GetReturnTypeLayout(su.DisplayContext) with + | Some tags -> + let typeStr = tagsToStr tags + // Format as F# type annotation: (name: Type) + addHint r.StartLine $"({mfv.DisplayName}: {typeStr})" + | None -> + // Fallback: try FullType + try addHint r.StartLine $"({mfv.DisplayName}: {mfv.FullType.Format(su.DisplayContext)})" + with _ -> () + | :? FSharpField as fld -> + try addHint r.StartLine $"({fld.DisplayName}: {fld.FieldType.Format(su.DisplayContext)})" + with _ -> () + | _ -> () + // Render lines with inline type comments + let sb = StringBuilder() + for i in startLine .. endLine do + if i >= 1 && i <= sourceLines.Length then + let line = sourceLines.[i - 1] + match annotations.TryGetValue(i) with + | true, hints -> + let comment = hints |> Seq.distinct |> String.concat " " + sb.AppendLine($"{line} // {comment}") |> ignore + | _ -> + sb.AppendLine(line) |> ignore + return sb.ToString().TrimEnd() + + | "shutdown" -> + cts.Cancel() + return """{ "status":"shutting_down" }""" + + | other -> return $"ERROR: unknown command: {other}" + with ex -> + return $"ERROR: {ex.Message}" + } + + File.WriteAllText(metaPath, $"""{{ "repoRoot":"{config.RepoRoot}", "pid":{Environment.ProcessId} }}""") + + use listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified) + listener.Bind(UnixDomainSocketEndPoint(socketPath)) + listener.Listen(10) + File.SetUnixFileMode(socketPath, UnixFileMode.UserRead ||| UnixFileMode.UserWrite ||| UnixFileMode.UserExecute) + eprintfn $"[fsharp-diag] Listening on {socketPath} (pid {Environment.ProcessId})" + + // Idle timeout + Async.Start( + async { + while not cts.Token.IsCancellationRequested do + do! Async.Sleep(60_000 * 60) + if (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > config.IdleTimeoutMinutes then + eprintfn "[fsharp-diag] Idle timeout"; cts.Cancel() + }, cts.Token) + + try + while not cts.Token.IsCancellationRequested do + let! client = listener.AcceptAsync(cts.Token).AsTask() |> Async.AwaitTask + Async.Start( + async { + try + use client = client + use stream = new NetworkStream(client) + use reader = new StreamReader(stream) + use writer = new StreamWriter(stream, AutoFlush = true) + let! line = reader.ReadLineAsync() |> Async.AwaitTask + if line <> null && line.Length > 0 then + let! response = handleRequest line + do! writer.WriteLineAsync(response) |> Async.AwaitTask + with ex -> eprintfn $"[fsharp-diag] Client error: {ex.Message}" + }, cts.Token) + with + | :? OperationCanceledException -> () + | ex -> eprintfn $"[fsharp-diag] Error: {ex.Message}" + + try File.Delete(socketPath) with _ -> () + try File.Delete(metaPath) with _ -> () + eprintfn "[fsharp-diag] Shut down." + }