Merge pull request #604 from pbednarcik/feat/csharp-ls-solution
This commit is contained in:
+11
-6
@@ -168,9 +168,12 @@ The router applies these defaults in `cmd/gortex/server.go` and
|
||||
| `WithMaxAlive` | 6 servers | LRU eviction kicks in when a seventh distinct server would spawn — the least-recently-used one closes. |
|
||||
|
||||
These defaults suit a polyglot workspace where most languages are
|
||||
touched only intermittently. Override them by editing the
|
||||
`lsp.NewRouter(...).With...` chain in your build if you need a longer
|
||||
warm pool or a tighter memory bound.
|
||||
touched only intermittently. The idle timeout is overridable at runtime:
|
||||
`GORTEX_LSP_IDLE_TTL` accepts a Go duration (`45m`, `2h`); `0` or a
|
||||
negative value disables reaping entirely. Useful when a long enrichment
|
||||
window should keep a warm server around — a big Roslyn workspace is
|
||||
expensive to reload. The reaper interval and max-alive bound are still
|
||||
compile-time (`lsp.NewRouter(...).With...`).
|
||||
|
||||
### Workspace roots
|
||||
|
||||
@@ -400,8 +403,10 @@ Gortex applies two complementary, C#-scoped fixes, **both on by default**:
|
||||
|
||||
| Env var | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore -p:NuGetAudit=false` in the workspace so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. |
|
||||
| `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore <resolved solution> -p:NuGetAudit=false` in the workspace (falling back to a bare directory restore when no solution resolves) so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. |
|
||||
| `GORTEX_LSP_CSHARP_DIAG_FILTER` | on | Strip diagnostics whose code is the `NU####` NuGet family from `publishDiagnostics` before storing / fanning out (symptom fix). Deliberately narrow — real `CS####` compiler diagnostics always pass through. |
|
||||
| `GORTEX_LSP_CSHARP_SOLUTION` | unset | Solution csharp-ls loads and the pre-restore targets — a PATH-style list of `.sln`/`.slnx` paths (relative to a workspace root, or absolute inside one); each tracked root uses the first entry that resolves inside it, so one daemon-global value serves a multi-repo daemon. Unset auto-detects a lone root-level solution; a falsy value (`0` / `off` / `false` / `no` / `none`) disables injection and auto-detect, leaving csharp-ls's own recursive discovery untouched. The resolved pin and any ignored entries are logged at spawn. |
|
||||
| `GORTEX_LSP_RESOLVER_CSHARP` | off | Opt-in: add csharp-ls to the resolve-time LSP helper for repos with C# intent (root-level `.sln`/`.slnx`/`.csproj`). Any set, non-falsy value enables (`1` / `true` / `on` / `yes`); off by default because a Roslyn workspace load costs real memory and startup time. |
|
||||
|
||||
Set either to a falsey value (`0` / `off` / `false` / `none`) to disable it —
|
||||
e.g. `GORTEX_LSP_CSHARP_RESTORE=0` for offline / air-gapped indexing or to
|
||||
@@ -440,8 +445,8 @@ for repositories you trust.
|
||||
the one workspace — the spec stays available for every other tracked
|
||||
repo.
|
||||
- **Server keeps restarting:** the idle reaper closed it, then the
|
||||
next request re-spawned. Increase `WithIdleTimeout` if this hurts
|
||||
warm-cache benchmarks.
|
||||
next request re-spawned. Raise `GORTEX_LSP_IDLE_TTL` (Go duration;
|
||||
`0` disables reaping) if this hurts warm-cache benchmarks.
|
||||
- **High memory under polyglot load:** lower `WithMaxAlive` from 6 to
|
||||
3-4. The LRU evicts the least-recent server transparently.
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CSharpSolutionEnv names the solution file csharp-ls should load and the C#
|
||||
// pre-restore should target — a PATH-style list of candidates (relative to
|
||||
// the workspace root, or absolute inside it), each root using the first
|
||||
// entry that resolves inside it. The variable is daemon-global while
|
||||
// solutions are per-workspace, so per-root resolution is what makes one
|
||||
// setting serve a multi-repo daemon: entries that do not resolve inside a
|
||||
// given root are ignored for that root (and logged), and several repos with
|
||||
// different solutions each pick their own entry.
|
||||
//
|
||||
// Empty (the default) auto-detects: a workspace root carrying exactly one
|
||||
// root-level .sln/.slnx uses it; anything else keeps the server's own
|
||||
// discovery. Current csharp-ls discovers on its own — recursive
|
||||
// .sln/.slnx glob with a most-projects heuristic since 0.19.0, .slnx since
|
||||
// 0.18.0, per-.csproj fallback — so the pin's value is determinism, a
|
||||
// concrete target for the pre-spawn restore, and an operator override, not
|
||||
// a capability the server lacks. An explicit falsy value
|
||||
// (0/false/no/off/n/none) disables injection and auto-detect entirely,
|
||||
// leaving the server's discovery untouched.
|
||||
const CSharpSolutionEnv = "GORTEX_LSP_CSHARP_SOLUTION"
|
||||
|
||||
// isCSharpLSCommand reports whether a resolved LSP command is csharp-ls,
|
||||
// whether configured bare, as a path, or with a Windows .exe suffix.
|
||||
func isCSharpLSCommand(command string) bool {
|
||||
return strings.TrimSuffix(filepath.Base(command), ".exe") == "csharp-ls"
|
||||
}
|
||||
|
||||
// csharpSolutionEnvOff reports whether the env value is an explicit off
|
||||
// switch — the IsFalsyEnv value vocabulary plus "none" for path-flavored
|
||||
// configs.
|
||||
func csharpSolutionEnvOff(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "0", "false", "no", "off", "n", "none":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// csharpSolutionChoice is a resolved solution pin plus the provenance
|
||||
// callers log: how the solution was chosen and which env entries were
|
||||
// dropped on the way. A silently ignored mis-spelled entry otherwise
|
||||
// presents to the operator as an unpinned workspace with no explanation.
|
||||
type csharpSolutionChoice struct {
|
||||
solution string // argv spelling; "" = no pin
|
||||
source string // "env" or "auto-detect" when solution != ""
|
||||
ignored []string // env entries skipped for this root, with reason
|
||||
}
|
||||
|
||||
// csharpSolutionResolution resolves the solution pin for a workspace root:
|
||||
// the first CSharpSolutionEnv entry that names a file inside the root, else
|
||||
// the single root-level .sln/.slnx when exactly one exists, else no pin.
|
||||
// The returned spelling is what the argv carries — the env value as given,
|
||||
// or the bare file name for an auto-detected root solution; both resolve
|
||||
// against the server's working directory, which is the workspace root.
|
||||
func csharpSolutionResolution(workspaceRoot string) csharpSolutionChoice {
|
||||
raw := os.Getenv(CSharpSolutionEnv)
|
||||
if csharpSolutionEnvOff(raw) {
|
||||
return csharpSolutionChoice{}
|
||||
}
|
||||
var choice csharpSolutionChoice
|
||||
for _, entry := range strings.Split(raw, string(os.PathListSeparator)) {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
abs := entry
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(workspaceRoot, entry)
|
||||
}
|
||||
switch {
|
||||
case !pathInsideRoot(workspaceRoot, abs):
|
||||
choice.ignored = append(choice.ignored, entry+" (outside the workspace root)")
|
||||
case !solutionFileExists(abs):
|
||||
choice.ignored = append(choice.ignored, entry+" (no such file)")
|
||||
default:
|
||||
choice.solution = entry
|
||||
choice.source = "env"
|
||||
return choice
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(workspaceRoot)
|
||||
if err != nil {
|
||||
return choice
|
||||
}
|
||||
found := ""
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(e.Name())) {
|
||||
case ".sln", ".slnx":
|
||||
if found != "" {
|
||||
return choice // several root solutions are ambiguous — no pick
|
||||
}
|
||||
found = e.Name()
|
||||
}
|
||||
}
|
||||
if found != "" {
|
||||
choice.solution = found
|
||||
choice.source = "auto-detect"
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
// csharpSolutionFor is the pin without provenance — see
|
||||
// csharpSolutionResolution.
|
||||
func csharpSolutionFor(workspaceRoot string) string {
|
||||
return csharpSolutionResolution(workspaceRoot).solution
|
||||
}
|
||||
|
||||
// csharpSolutionArgs appends `--solution <file>` to a csharp-ls launch argv
|
||||
// when a solution resolves for the workspace root and the caller has not
|
||||
// already pinned one (bare or =-joined spelling). The explicit pin keeps
|
||||
// the loaded workspace deterministic and names the same file the targeted
|
||||
// pre-restore uses; csharp-ls's own discovery would otherwise re-choose
|
||||
// recursively on every spawn.
|
||||
func csharpSolutionArgs(baseArgs []string, workspaceRoot string) []string {
|
||||
for _, a := range baseArgs {
|
||||
if a == "--solution" || a == "-s" ||
|
||||
strings.HasPrefix(a, "--solution=") || strings.HasPrefix(a, "-s=") {
|
||||
return baseArgs
|
||||
}
|
||||
}
|
||||
sln := csharpSolutionFor(workspaceRoot)
|
||||
if sln == "" {
|
||||
return baseArgs
|
||||
}
|
||||
out := append([]string(nil), baseArgs...)
|
||||
return append(out, "--solution", sln)
|
||||
}
|
||||
|
||||
// csharpRestoreArgs builds the pre-spawn `dotnet` argv: targeted at the
|
||||
// resolved solution when one exists, else the bare directory restore. A bare
|
||||
// restore fails with MSB1011 when the working directory is ambiguous about
|
||||
// what to restore, so the audit-suppressed assets the workspace load needs
|
||||
// are never written — targeting the same solution the server loads closes
|
||||
// that hole.
|
||||
func csharpRestoreArgs(workspaceRoot string) []string {
|
||||
if sln := csharpSolutionFor(workspaceRoot); sln != "" {
|
||||
return []string{"restore", sln, "-p:NuGetAudit=false"}
|
||||
}
|
||||
return []string{"restore", "-p:NuGetAudit=false"}
|
||||
}
|
||||
|
||||
// pathInsideRoot reports whether path (absolute) lies under root.
|
||||
func pathInsideRoot(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// solutionFileExists reports whether path names an existing regular file.
|
||||
func solutionFileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func writeSolutionFile(t *testing.T, root string, rel string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, rel)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte("solution"), 0o644))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestIsCSharpLSCommand(t *testing.T) {
|
||||
assert.True(t, isCSharpLSCommand("csharp-ls"))
|
||||
assert.True(t, isCSharpLSCommand(filepath.Join("some", "tools", "csharp-ls")))
|
||||
assert.True(t, isCSharpLSCommand("csharp-ls.exe"))
|
||||
assert.False(t, isCSharpLSCommand("omnisharp"))
|
||||
assert.False(t, isCSharpLSCommand("jdtls"))
|
||||
}
|
||||
|
||||
func TestCSharpSolutionForEnvWinsWhenInsideRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSolutionFile(t, root, filepath.Join("projects", "All.slnx"))
|
||||
// Decoys: multiple root-level solutions must not matter when env resolves.
|
||||
writeSolutionFile(t, root, "A.sln")
|
||||
writeSolutionFile(t, root, "B.sln")
|
||||
|
||||
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
|
||||
assert.Equal(t, filepath.Join("projects", "All.slnx"), csharpSolutionFor(root))
|
||||
|
||||
// Absolute spelling of a solution inside the root is used as-is.
|
||||
abs := filepath.Join(root, "projects", "All.slnx")
|
||||
t.Setenv(CSharpSolutionEnv, abs)
|
||||
assert.Equal(t, abs, csharpSolutionFor(root))
|
||||
}
|
||||
|
||||
func TestCSharpSolutionForEnvIgnoredOutsideRoot(t *testing.T) {
|
||||
// A daemon-global env value must be per-workspace safe: a path that does
|
||||
// not resolve inside THIS root is ignored and auto-detection takes over.
|
||||
root := t.TempDir()
|
||||
other := t.TempDir()
|
||||
writeSolutionFile(t, root, "Probe.sln")
|
||||
otherSln := writeSolutionFile(t, other, filepath.Join("projects", "All.slnx"))
|
||||
|
||||
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
|
||||
assert.Equal(t, "Probe.sln", csharpSolutionFor(root),
|
||||
"missing relative env path must fall through to auto-detect")
|
||||
|
||||
t.Setenv(CSharpSolutionEnv, otherSln)
|
||||
assert.Equal(t, "Probe.sln", csharpSolutionFor(root),
|
||||
"absolute env path outside the root must fall through to auto-detect")
|
||||
}
|
||||
|
||||
func TestCSharpSolutionForListPinsPerRoot(t *testing.T) {
|
||||
// One daemon-global value, several tracked repos: a path-list entry
|
||||
// (PATH-style separator) pins each root via the first entry that
|
||||
// resolves inside it.
|
||||
rootA := t.TempDir()
|
||||
rootB := t.TempDir()
|
||||
writeSolutionFile(t, rootA, filepath.Join("projects", "All.slnx"))
|
||||
writeSolutionFile(t, rootA, "Extra.sln") // multi-solution: no auto-pick
|
||||
writeSolutionFile(t, rootB, filepath.Join("src", "Other.sln"))
|
||||
writeSolutionFile(t, rootB, "Extra.sln")
|
||||
|
||||
t.Setenv(CSharpSolutionEnv,
|
||||
filepath.Join("projects", "All.slnx")+string(os.PathListSeparator)+filepath.Join("src", "Other.sln"))
|
||||
assert.Equal(t, filepath.Join("projects", "All.slnx"), csharpSolutionFor(rootA))
|
||||
assert.Equal(t, filepath.Join("src", "Other.sln"), csharpSolutionFor(rootB))
|
||||
|
||||
// A root where no entry resolves still falls through to auto-detect.
|
||||
rootC := t.TempDir()
|
||||
writeSolutionFile(t, rootC, "Only.sln")
|
||||
assert.Equal(t, "Only.sln", csharpSolutionFor(rootC))
|
||||
}
|
||||
|
||||
func TestCSharpSolutionForAutoDetect(t *testing.T) {
|
||||
t.Setenv(CSharpSolutionEnv, "")
|
||||
|
||||
single := t.TempDir()
|
||||
writeSolutionFile(t, single, "Only.sln")
|
||||
assert.Equal(t, "Only.sln", csharpSolutionFor(single))
|
||||
|
||||
slnx := t.TempDir()
|
||||
writeSolutionFile(t, slnx, "Only.slnx")
|
||||
assert.Equal(t, "Only.slnx", csharpSolutionFor(slnx))
|
||||
|
||||
multi := t.TempDir()
|
||||
writeSolutionFile(t, multi, "A.sln")
|
||||
writeSolutionFile(t, multi, "B.slnx")
|
||||
assert.Equal(t, "", csharpSolutionFor(multi),
|
||||
"several root solutions are ambiguous — no automatic pick")
|
||||
|
||||
nested := t.TempDir()
|
||||
writeSolutionFile(t, nested, filepath.Join("sub", "Deep.sln"))
|
||||
assert.Equal(t, "", csharpSolutionFor(nested),
|
||||
"auto-detect scans the workspace root only")
|
||||
|
||||
assert.Equal(t, "", csharpSolutionFor(t.TempDir()))
|
||||
}
|
||||
|
||||
func TestCSharpSolutionForOffSentinelDisablesPinning(t *testing.T) {
|
||||
// One env var covers the whole feature: a path list pins, empty
|
||||
// auto-detects, and an explicit falsy value switches injection AND
|
||||
// auto-detect off so the server's own discovery runs untouched.
|
||||
root := t.TempDir()
|
||||
writeSolutionFile(t, root, "Only.sln")
|
||||
for _, v := range []string{"off", "none", "0", "false", "no", "OFF"} {
|
||||
t.Setenv(CSharpSolutionEnv, v)
|
||||
assert.Equal(t, "", csharpSolutionFor(root), "sentinel %q must disable auto-detect", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSharpSolutionResolutionReportsSourceAndIgnored(t *testing.T) {
|
||||
// Callers log how the solution was chosen and which env entries were
|
||||
// dropped — a silently ignored mis-spelled entry otherwise presents
|
||||
// as the exact symptom the pin exists to fix.
|
||||
root := t.TempDir()
|
||||
writeSolutionFile(t, root, "Only.sln")
|
||||
|
||||
t.Setenv(CSharpSolutionEnv, "")
|
||||
choice := csharpSolutionResolution(root)
|
||||
assert.Equal(t, "Only.sln", choice.solution)
|
||||
assert.Equal(t, "auto-detect", choice.source)
|
||||
assert.Empty(t, choice.ignored)
|
||||
|
||||
t.Setenv(CSharpSolutionEnv, "missing.sln"+string(os.PathListSeparator)+"Only.sln")
|
||||
choice = csharpSolutionResolution(root)
|
||||
assert.Equal(t, "Only.sln", choice.solution)
|
||||
assert.Equal(t, "env", choice.source)
|
||||
require.Len(t, choice.ignored, 1)
|
||||
assert.Contains(t, choice.ignored[0], "missing.sln")
|
||||
}
|
||||
|
||||
func TestCSharpSolutionArgs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSolutionFile(t, root, "Only.sln")
|
||||
t.Setenv(CSharpSolutionEnv, "")
|
||||
|
||||
assert.Equal(t, []string{"--solution", "Only.sln"}, csharpSolutionArgs(nil, root))
|
||||
assert.Equal(t, []string{"--stdio", "--solution", "Only.sln"},
|
||||
csharpSolutionArgs([]string{"--stdio"}, root))
|
||||
|
||||
// A caller-supplied solution wins, in every spelling — bare token
|
||||
// and the =-joined form config args can carry.
|
||||
explicit := []string{"--solution", "Custom.sln"}
|
||||
assert.Equal(t, explicit, csharpSolutionArgs(explicit, root))
|
||||
short := []string{"-s", "Custom.sln"}
|
||||
assert.Equal(t, short, csharpSolutionArgs(short, root))
|
||||
joined := []string{"--solution=Custom.sln"}
|
||||
assert.Equal(t, joined, csharpSolutionArgs(joined, root))
|
||||
shortJoined := []string{"-s=Custom.sln"}
|
||||
assert.Equal(t, shortJoined, csharpSolutionArgs(shortJoined, root))
|
||||
|
||||
// No resolvable solution leaves the argv untouched.
|
||||
empty := t.TempDir()
|
||||
base := []string{"--stdio"}
|
||||
assert.Equal(t, base, csharpSolutionArgs(base, empty))
|
||||
}
|
||||
|
||||
func TestCSharpRestoreArgsTargetSolution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(CSharpSolutionEnv, "")
|
||||
assert.Equal(t, []string{"restore", "-p:NuGetAudit=false"}, csharpRestoreArgs(root),
|
||||
"no solution keeps the bare directory restore")
|
||||
|
||||
writeSolutionFile(t, root, filepath.Join("projects", "All.slnx"))
|
||||
writeSolutionFile(t, root, "A.sln") // multi-solution root: bare restore would fail MSB1011
|
||||
t.Setenv(CSharpSolutionEnv, filepath.Join("projects", "All.slnx"))
|
||||
assert.Equal(t,
|
||||
[]string{"restore", filepath.Join("projects", "All.slnx"), "-p:NuGetAudit=false"},
|
||||
csharpRestoreArgs(root),
|
||||
"a resolved solution becomes the restore target")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIdleTimeoutFromEnv(t *testing.T) {
|
||||
const fallback = 10 * time.Minute
|
||||
|
||||
t.Setenv(IdleTTLEnv, "")
|
||||
if got := IdleTimeoutFromEnv(fallback); got != fallback {
|
||||
t.Fatalf("unset: got %v, want fallback %v", got, fallback)
|
||||
}
|
||||
t.Setenv(IdleTTLEnv, "45m")
|
||||
if got := IdleTimeoutFromEnv(fallback); got != 45*time.Minute {
|
||||
t.Fatalf("45m: got %v", got)
|
||||
}
|
||||
// Zero (and negative, clamped to zero) disables the reaper — the
|
||||
// router's idleTimeout <= 0 no-op.
|
||||
t.Setenv(IdleTTLEnv, "0")
|
||||
if got := IdleTimeoutFromEnv(fallback); got != 0 {
|
||||
t.Fatalf("0: got %v, want 0", got)
|
||||
}
|
||||
t.Setenv(IdleTTLEnv, "-5m")
|
||||
if got := IdleTimeoutFromEnv(fallback); got != 0 {
|
||||
t.Fatalf("-5m: got %v, want 0", got)
|
||||
}
|
||||
t.Setenv(IdleTTLEnv, "not-a-duration")
|
||||
if got := IdleTimeoutFromEnv(fallback); got != fallback {
|
||||
t.Fatalf("unparseable: got %v, want fallback %v", got, fallback)
|
||||
}
|
||||
}
|
||||
@@ -1914,6 +1914,29 @@ func (p *Provider) dialOrSpawn(workspaceRoot string) (*Client, error) {
|
||||
if isJdtlsCommand(p.command) {
|
||||
args = jdtlsDataArgs(args, workspaceRoot)
|
||||
}
|
||||
// Pin the workspace's resolved solution (env or lone root .sln/.slnx)
|
||||
// so the loaded workspace is deterministic and matches the targeted
|
||||
// pre-restore — see csharpSolutionArgs. Log the choice and any dropped
|
||||
// env entries: a silently ignored mis-spelling otherwise presents as an
|
||||
// unpinned workspace with no explanation.
|
||||
if isCSharpLSCommand(p.command) {
|
||||
before := len(args)
|
||||
args = csharpSolutionArgs(args, workspaceRoot)
|
||||
if p.logger != nil {
|
||||
choice := csharpSolutionResolution(workspaceRoot)
|
||||
for _, entry := range choice.ignored {
|
||||
p.logger.Warn("lsp: csharp solution env entry ignored",
|
||||
zap.String("entry", entry),
|
||||
zap.String("workspace", workspaceRoot))
|
||||
}
|
||||
if len(args) > before {
|
||||
p.logger.Info("lsp: csharp solution pinned",
|
||||
zap.String("solution", choice.solution),
|
||||
zap.String("source", choice.source),
|
||||
zap.String("workspace", workspaceRoot))
|
||||
}
|
||||
}
|
||||
}
|
||||
return NewClient(p.command, args, p.env, workspaceRoot, p.logger)
|
||||
}
|
||||
|
||||
@@ -2100,7 +2123,10 @@ func (p *Provider) maybeCSharpPreRestore(workspaceRoot string) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), csharpRestoreTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "dotnet", "restore", "-p:NuGetAudit=false")
|
||||
// Target the same solution the server will load (when one resolves) — a
|
||||
// bare restore fails with MSB1011 in a multi-solution root, leaving the
|
||||
// audit-suppressed assets unwritten. See csharpRestoreArgs.
|
||||
cmd := exec.CommandContext(ctx, "dotnet", csharpRestoreArgs(workspaceRoot)...)
|
||||
platform.ConfigureBackgroundCommand(cmd)
|
||||
cmd.Dir = workspaceRoot
|
||||
cmd.Env = append(os.Environ(), p.env...)
|
||||
@@ -2116,7 +2142,8 @@ func (p *Provider) maybeCSharpPreRestore(workspaceRoot string) {
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Info("lsp: csharp pre-restore complete (NuGetAudit suppressed)",
|
||||
zap.String("workspace", workspaceRoot))
|
||||
zap.String("workspace", workspaceRoot),
|
||||
zap.String("solution", csharpSolutionFor(workspaceRoot)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,9 +461,14 @@ var Servers = []ServerSpec{
|
||||
Daemon: true,
|
||||
MaxParallel: 6,
|
||||
// csharp-ls is a Roslyn stdio LSP (`dotnet tool install csharp-ls`)
|
||||
// that speaks plain LSP with no args and auto-discovers a .sln under
|
||||
// the workspace root. It is far more commonly installed than
|
||||
// OmniSharp on dev machines, so try it when omnisharp is not on PATH.
|
||||
// that speaks plain LSP with no args. Current versions discover
|
||||
// solutions on their own (recursive .sln/.slnx glob, most-projects
|
||||
// heuristic since 0.19.0); gortex still pins the resolved solution
|
||||
// at spawn via csharpSolutionArgs for determinism, to name the
|
||||
// same file the targeted pre-restore uses, and as the operator
|
||||
// override when the server's heuristic picks wrong. It is far more
|
||||
// commonly installed than OmniSharp on dev machines, so try it when
|
||||
// omnisharp is not on PATH.
|
||||
AlternativeCommands: []ServerAlt{
|
||||
{Command: "csharp-ls"},
|
||||
},
|
||||
|
||||
@@ -362,6 +362,31 @@ func (r *Router) WithIdleTimeout(d time.Duration) *Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// IdleTTLEnv overrides the router's provider idle timeout — a Go duration
|
||||
// ("45m", "2h"); zero or negative disables reaping entirely. Exists because
|
||||
// one timeout cannot fit every server class: a Roslyn or jdtls workspace can
|
||||
// take longer to load than the default idle window, and a server reaped
|
||||
// mid-load never becomes useful — every later pass pays the load again.
|
||||
const IdleTTLEnv = "GORTEX_LSP_IDLE_TTL"
|
||||
|
||||
// IdleTimeoutFromEnv resolves the router idle timeout: the IdleTTLEnv
|
||||
// duration when set and parseable (negative clamps to 0 = never reap),
|
||||
// else fallback.
|
||||
func IdleTimeoutFromEnv(fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(IdleTTLEnv))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// WithAdditionalWorkspaceFolders sets extra directory roots advertised
|
||||
// to every LSP server's initialize request alongside the primary
|
||||
// workspace root, enabling cross-package resolution. Builder-style.
|
||||
|
||||
@@ -91,6 +91,33 @@ func RepoLikelyHasPythonIntent(absRoot string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RepoLikelyHasCSharpIntent reports whether a repo root carries a root-level
|
||||
// .NET project marker (a solution or project file), used to decide whether to
|
||||
// wire the resolve-time C# helper for a tracked repo. Root-level only, like
|
||||
// the TS/Python probes.
|
||||
func RepoLikelyHasCSharpIntent(absRoot string) bool {
|
||||
for _, pattern := range []string{"*.sln", "*.slnx", "*.csproj"} {
|
||||
if matches, err := filepath.Glob(filepath.Join(absRoot, pattern)); err == nil && len(matches) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CSharpResolverEnv opts the resolve-time C# LSP helper in. OFF by default —
|
||||
// unlike tsserver/pyright, the Roslyn workspace load behind csharp-ls costs
|
||||
// minutes and hundreds of MB on a large solution, so repos only pay it when
|
||||
// the operator asks for LSP-grade C# resolution ("1" / "true").
|
||||
const CSharpResolverEnv = "GORTEX_LSP_RESOLVER_CSHARP"
|
||||
|
||||
// csharpResolverHelperEnabled reports whether the resolve-time C# helper may
|
||||
// be wired for repos with C# intent.
|
||||
func csharpResolverHelperEnabled() bool {
|
||||
// Set-and-not-falsy, mirroring the sibling GORTEX_LSP_RESOLVER's
|
||||
// value vocabulary — "on"/"yes" enable instead of failing silently.
|
||||
return strings.TrimSpace(os.Getenv(CSharpResolverEnv)) != "" && !IsFalsyEnv(CSharpResolverEnv)
|
||||
}
|
||||
|
||||
// BuildResolverLSPHelper constructs the resolve-time LSP helper for a
|
||||
// workspace, choosing the router-cached lazy path (poolSize <= 1, reuses
|
||||
// the router's idle reaper) or the fresh-spawn pool path (poolSize > 1,
|
||||
@@ -144,6 +171,10 @@ func BuildResolverLSPHelperForRepo(router *lsp.Router, absRoot string, poolSize
|
||||
|
||||
add(preferredSpecName(router.Available, ".ts"), RepoLikelyHasTypeScriptIntent(absRoot))
|
||||
add(preferredSpecName(router.Available, ".py"), RepoLikelyHasPythonIntent(absRoot))
|
||||
// C# is opt-in (CSharpResolverEnv): the Roslyn workspace load behind the
|
||||
// helper is far heavier than the TS/Python servers'.
|
||||
add(preferredSpecName(router.Available, ".cs"),
|
||||
csharpResolverHelperEnabled() && RepoLikelyHasCSharpIntent(absRoot))
|
||||
|
||||
return lsp.NewResolverHelperMux(helpers...), specs
|
||||
}
|
||||
|
||||
@@ -60,3 +60,57 @@ func TestRepoLikelyHasPythonIntent_RootScript(t *testing.T) {
|
||||
t.Fatalf("root-level .py file should mark a Python repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoLikelyHasCSharpIntent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if RepoLikelyHasCSharpIntent(dir) {
|
||||
t.Fatalf("empty temp dir should not look like a C# repo")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "App.slnx"), []byte("<Solution/>\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !RepoLikelyHasCSharpIntent(dir) {
|
||||
t.Fatalf("root-level .slnx should mark a C# repo")
|
||||
}
|
||||
|
||||
proj := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(proj, "App.csproj"), []byte("<Project/>\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !RepoLikelyHasCSharpIntent(proj) {
|
||||
t.Fatalf("root-level .csproj should mark a C# repo")
|
||||
}
|
||||
|
||||
nested := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(nested, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "src", "App.sln"), []byte("sln\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if RepoLikelyHasCSharpIntent(nested) {
|
||||
t.Fatalf("intent probes are root-level only, like the TS/Python ones")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSharpResolverHelperOptIn(t *testing.T) {
|
||||
t.Setenv(CSharpResolverEnv, "")
|
||||
if csharpResolverHelperEnabled() {
|
||||
t.Fatalf("resolve-time C# LSP must be opt-in: unset means disabled")
|
||||
}
|
||||
// Any set, non-falsy value enables — the same value vocabulary
|
||||
// IsFalsyEnv uses, so "on"/"yes" behave like the sibling
|
||||
// GORTEX_LSP_RESOLVER instead of being silently ignored.
|
||||
for _, v := range []string{"1", "true", "TRUE", "on", "yes"} {
|
||||
t.Setenv(CSharpResolverEnv, v)
|
||||
if !csharpResolverHelperEnabled() {
|
||||
t.Fatalf("%q should enable the resolve-time C# helper", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"0", "false", "no", "off", "n"} {
|
||||
t.Setenv(CSharpResolverEnv, v)
|
||||
if csharpResolverHelperEnabled() {
|
||||
t.Fatalf("%q should disable the resolve-time C# helper", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) {
|
||||
// one that forgets now fails loudly instead of spawning a language
|
||||
// server in the daemon's launch directory.
|
||||
lspRouter := lsp.NewRouter(cfg.Index, logger).
|
||||
WithIdleTimeout(10 * time.Minute).
|
||||
WithIdleTimeout(lsp.IdleTimeoutFromEnv(10 * time.Minute)).
|
||||
WithReaperInterval(time.Minute).
|
||||
WithMaxAlive(6).
|
||||
WithAdditionalWorkspaceFolders(conf.Semantic.AdditionalWorkspaceFolders).
|
||||
|
||||
Reference in New Issue
Block a user