Skip to content

Commit 4ae9fa7

Browse files
T-GroCopilot
andcommitted
Make fsharp-diagnostics skill cross-platform
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 022cd1a commit 4ae9fa7

3 files changed

Lines changed: 205 additions & 131 deletions

File tree

.github/skills/fsharp-diagnostics/SKILL.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,44 @@ description: "Always invoke after editing .fs files. Provides fast parse/typeche
99

1010
## Setup (run once per shell session)
1111

12-
```bash
13-
GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; }
12+
Works on macOS, Linux, and Windows — requires pwsh 7+ (`brew install powershell` / `winget install Microsoft.PowerShell` / `apt install powershell`).
13+
14+
```pwsh
15+
function GetErrors { & "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1" @args }
1416
```
1517

18+
If your shell is bash/zsh and you don't want to switch, the script also runs as `pwsh -File <path>/get-fsharp-errors.ps1 ...`.
19+
1620
## Parse first, typecheck second
1721

18-
```bash
19-
GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs
22+
```pwsh
23+
GetErrors -ParseOnly src/Compiler/Checking/CheckBasics.fs
2024
```
2125
If errors → fix syntax. Do NOT typecheck until parse is clean.
22-
```bash
26+
```pwsh
2327
GetErrors src/Compiler/Checking/CheckBasics.fs
2428
```
2529

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

2832
Before renaming or to understand call sites:
29-
```bash
30-
GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5
33+
```pwsh
34+
GetErrors -FindRefs src/Compiler/Checking/CheckBasics.fs 30 5
3135
```
3236

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

3539
To see inferred types as inline `// (name: Type)` comments:
36-
```bash
37-
GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032
40+
```pwsh
41+
GetErrors -TypeHints src/Compiler/TypedTree/TypedTreeOps.Transforms.fs 100 120
3842
```
3943

4044
## Other
4145

42-
```bash
43-
GetErrors --check-project # typecheck entire project
44-
GetErrors --ping
45-
GetErrors --shutdown
46+
```pwsh
47+
GetErrors -CheckProject # typecheck entire project
48+
GetErrors -Ping
49+
GetErrors -Shutdown
4650
```
4751

4852
First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<#
2+
get-fsharp-errors.ps1 - cross-platform client for the fsharp-diag-server.
3+
Requires pwsh 7+ (AF_UNIX socket support on Windows 10 1803+).
4+
#>
5+
6+
[CmdletBinding(PositionalBinding = $false)]
7+
param(
8+
[switch]$ParseOnly,
9+
[switch]$CheckProject,
10+
[switch]$Ping,
11+
[switch]$Shutdown,
12+
[switch]$FindRefs,
13+
[switch]$TypeHints,
14+
[Parameter(ValueFromRemainingArguments = $true)]
15+
[string[]]$Rest
16+
)
17+
18+
$ErrorActionPreference = 'Stop'
19+
Set-StrictMode -Version Latest
20+
21+
$ScriptDir = Split-Path -Parent $PSCommandPath
22+
$ServerProject = (Resolve-Path (Join-Path $ScriptDir '..' 'server')).Path
23+
$SockDir = Join-Path $HOME '.fsharp-diag'
24+
$StartTimeoutSec = 180 # > documented 70s cold start, covers slow nuget restore
25+
$ConnectTimeoutMs = 5000
26+
$IoTimeoutMs = 600000 # 10 min for checkProject; safe upper bound
27+
28+
function Show-Usage {
29+
@"
30+
Usage:
31+
get-fsharp-errors.ps1 [-ParseOnly] <file.fs>
32+
get-fsharp-errors.ps1 -FindRefs <file.fs> <line> <col>
33+
get-fsharp-errors.ps1 -TypeHints <file.fs> <startLine> <endLine>
34+
get-fsharp-errors.ps1 -CheckProject | -Ping | -Shutdown
35+
"@ | Out-Host
36+
}
37+
38+
function Get-RepoRoot {
39+
# Server normalizes via Path.GetFullPath; client must do the same before hashing.
40+
$raw = try { (& git rev-parse --show-toplevel 2>$null) } catch { $null }
41+
if ([string]::IsNullOrWhiteSpace($raw)) { $raw = (Get-Location).Path }
42+
[System.IO.Path]::GetFullPath($raw.Trim())
43+
}
44+
45+
function Get-Hash16([string]$s) {
46+
# Mirrors Server.fs deriveHash exactly.
47+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($s)
48+
[System.Convert]::ToHexString(
49+
[System.Security.Cryptography.SHA256]::HashData($bytes)
50+
).Substring(0, 16).ToLowerInvariant()
51+
}
52+
53+
function Get-SocketPath([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.sock') }
54+
function Get-LogPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.log') }
55+
function Get-LockPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.startup.lock') }
56+
57+
function Resolve-AbsFile([string]$p) {
58+
# Lexical resolution - missing files reach the server's JSON not-found handler.
59+
if ([System.IO.Path]::IsPathRooted($p)) {
60+
[System.IO.Path]::GetFullPath($p)
61+
} else {
62+
[System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $p))
63+
}
64+
}
65+
66+
function New-DiagSocket {
67+
New-Object System.Net.Sockets.Socket(
68+
[System.Net.Sockets.AddressFamily]::Unix,
69+
[System.Net.Sockets.SocketType]::Stream,
70+
[System.Net.Sockets.ProtocolType]::Unspecified)
71+
}
72+
73+
function Send-Request([string]$sock, [hashtable]$payload, [int]$timeoutMs = $IoTimeoutMs) {
74+
$json = ($payload | ConvertTo-Json -Compress -Depth 4) + "`n"
75+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
76+
$client = New-DiagSocket
77+
try {
78+
$client.SendTimeout = $timeoutMs
79+
$client.ReceiveTimeout = $timeoutMs
80+
$task = $client.ConnectAsync((New-Object System.Net.Sockets.UnixDomainSocketEndPoint($sock)))
81+
if (-not $task.Wait($ConnectTimeoutMs)) { throw "connect timed out after $ConnectTimeoutMs ms ($sock)" }
82+
[void]$client.Send($bytes)
83+
$client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
84+
# Stream UTF-8 across recv boundaries so multibyte chars don't fragment.
85+
$buf = New-Object byte[] 65536
86+
$decoder = [System.Text.Encoding]::UTF8.GetDecoder()
87+
$chars = New-Object char[] $buf.Length
88+
$sb = [System.Text.StringBuilder]::new()
89+
while (($n = $client.Receive($buf)) -gt 0) {
90+
$c = $decoder.GetChars($buf, 0, $n, $chars, 0)
91+
[void]$sb.Append($chars, 0, $c)
92+
}
93+
$sb.ToString()
94+
} finally { $client.Dispose() }
95+
}
96+
97+
function Test-ServerAlive([string]$sock) {
98+
if (-not (Test-Path $sock)) { return $false }
99+
try { (Send-Request $sock @{ command = 'ping' } 2000) -match '"ok"' } catch { $false }
100+
}
101+
102+
function Start-DiagServer([string]$root, [string]$sock) {
103+
if (Test-ServerAlive $sock) { return }
104+
New-Item -ItemType Directory -Force -Path $SockDir | Out-Null
105+
$lockPath = Get-LockPath $root
106+
$lock = $null
107+
try {
108+
# Serialize startup so racing clients don't spawn duplicate servers.
109+
$lock = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate,
110+
[System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
111+
# Re-check after acquiring the lock - peer may have started a server while we waited.
112+
if (Test-ServerAlive $sock) { return }
113+
if (Test-Path $sock) { Remove-Item -Force $sock }
114+
$log = Get-LogPath $root
115+
$psi = New-Object System.Diagnostics.ProcessStartInfo
116+
$psi.FileName = 'dotnet'
117+
# ArgumentList (Collection<string>) handles per-platform quoting (incl. spaces in paths).
118+
foreach ($a in @('run','-c','Release','--project',$ServerProject,'--','--repo-root',$root)) {
119+
[void]$psi.ArgumentList.Add($a)
120+
}
121+
$psi.RedirectStandardOutput = $true
122+
$psi.RedirectStandardError = $true
123+
$psi.UseShellExecute = $false
124+
$psi.CreateNoWindow = $true
125+
$proc = [System.Diagnostics.Process]::Start($psi)
126+
# Drain to log file so the child's pipes don't fill and block.
127+
$proc.StandardOutput.BaseStream.CopyToAsync([System.IO.File]::Create($log)) | Out-Null
128+
$proc.StandardError.BaseStream.CopyToAsync(
129+
[System.IO.File]::Create("$log.err")) | Out-Null
130+
# Poll for a LIVE server (file existence is insufficient - server may be mid-bind).
131+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
132+
while ($sw.Elapsed.TotalSeconds -lt $StartTimeoutSec) {
133+
if (Test-ServerAlive $sock) { return }
134+
Start-Sleep -Milliseconds 500
135+
}
136+
throw "Server failed to start within ${StartTimeoutSec}s. Check log: $log"
137+
} finally {
138+
if ($lock) { $lock.Dispose(); Remove-Item -Force $lockPath -ErrorAction SilentlyContinue }
139+
}
140+
}
141+
142+
function Assert-RequiredArg([int]$needed, [string]$cmd) {
143+
if (-not $Rest -or $Rest.Count -lt $needed) {
144+
Write-Error "$cmd requires $needed positional argument(s): see -? for usage." -ErrorAction Continue
145+
Show-Usage
146+
exit 1
147+
}
148+
}
149+
150+
function ConvertTo-Int32Arg([string]$s, [string]$name) {
151+
$out = 0
152+
if (-not [int]::TryParse($s, [ref]$out)) {
153+
Write-Error "$name must be an integer, got '$s'" -ErrorAction Continue
154+
Show-Usage
155+
exit 1
156+
}
157+
$out
158+
}
159+
160+
# --- main ---
161+
162+
$root = Get-RepoRoot
163+
$sock = Get-SocketPath $root
164+
165+
# Validate args BEFORE spawning a 70s+ server.
166+
$payload =
167+
if ($Ping) { @{ command = 'ping' } }
168+
elseif ($Shutdown) { @{ command = 'shutdown' } }
169+
elseif ($CheckProject) { @{ command = 'checkProject' } }
170+
elseif ($ParseOnly) { Assert-RequiredArg 1 '-ParseOnly'; @{ command = 'parseOnly'; file = (Resolve-AbsFile $Rest[0]) } }
171+
elseif ($FindRefs) { Assert-RequiredArg 3 '-FindRefs'; @{ command = 'findRefs'; file = (Resolve-AbsFile $Rest[0]); line = (ConvertTo-Int32Arg $Rest[1] 'line'); col = (ConvertTo-Int32Arg $Rest[2] 'col') } }
172+
elseif ($TypeHints) { Assert-RequiredArg 3 '-TypeHints'; @{ command = 'typeHints'; file = (Resolve-AbsFile $Rest[0]); startLine = (ConvertTo-Int32Arg $Rest[1] 'startLine'); endLine = (ConvertTo-Int32Arg $Rest[2] 'endLine') } }
173+
elseif ($Rest -and $Rest.Count -ge 1) { @{ command = 'check'; file = (Resolve-AbsFile $Rest[0]) } }
174+
else { Show-Usage; exit 1 }
175+
176+
# Skip server start for -Shutdown (would be pointless) and ensure friendly error if absent.
177+
if (-not $Shutdown) { Start-DiagServer $root $sock }
178+
179+
try {
180+
Send-Request $sock $payload
181+
} catch {
182+
if ($Shutdown) {
183+
Write-Output '{ "status":"not_running" }'
184+
} else {
185+
Write-Error "Cannot reach diagnostics server at $sock`: $($_.Exception.Message)"
186+
exit 1
187+
}
188+
}

.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh

Lines changed: 0 additions & 118 deletions
This file was deleted.

0 commit comments

Comments
 (0)