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
43 changes: 43 additions & 0 deletions docs/lsp.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,49 @@ Call `unsubscribe_diagnostics` to opt back out (idempotent).
Pair with `get_code_actions` + `apply_code_action` + `fix_all_in_file`
for the full edit-time diagnostic loop without polling.

## Language-specific behaviour

A few servers need per-language handling beyond the generic router. These
knobs are **environment variables** read by the daemon process — set them
where you launch `gortex daemon` / `gortex server`. There is no `.gortex.yaml`
key for them today.

### C# — NuGet audit advisories (`omnisharp` / `csharp-ls`)

The Roslyn `MSBuildWorkspace` these servers build escalates a NuGet audit
*warning* (the `NU19xx` family — e.g. a transitively vulnerable package) to a
**fatal** project-load failure and drops every project that references the
flagged package. Those projects then have no compilation, so the server emits
false `CS####` "unresolved type / namespace" diagnostics — even though
`dotnet build` / `dotnet test` keep `NU19xx` a non-fatal warning and succeed.

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_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. |

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
keep indexing off the network. Restore is on by default because gortex only
indexes repositories you explicitly add (it never auto-discovers), and
spawning the C# server already evaluates the project's MSBuild graph — so the
restore adds no execution surface beyond the workspace load it precedes. A
successful restore logs `lsp: csharp pre-restore complete (NuGetAudit
suppressed)`; a failure logs `lsp: csharp pre-restore failed; spawning server
anyway` with the restore output tail.

### Java — build-backed resolution (`jdtls`)

By default `jdtls` runs in a **no-build** mode (JRE-only classpath, Maven /
Gradle import and autobuild disabled) so indexing an untrusted Java repo never
runs its build. Resolution is more limited in this mode (jdtls falls back to
an "invisible project"). Set `GORTEX_LSP_JDTLS_TRUST_BUILD=1` (or `true`) to
allow full Maven / Gradle import + autobuild for higher-fidelity resolution —
**opt-in**, because it executes the repository's build tooling. Enable it only
for repositories you trust.

## Troubleshooting

- **`no_lsp_for` error:** the file extension didn't match any
Expand Down
127 changes: 127 additions & 0 deletions internal/semantic/lsp/csharp_diag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package lsp

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
)

// TestIsNuGetAdvisoryCode pins the advisory-code predicate: it must match the
// NU#### NuGet family (the audit/restore advisories we filter) and must NOT
// match real CS#### compiler codes — filtering those would hide genuine errors.
func TestIsNuGetAdvisoryCode(t *testing.T) {
cases := []struct {
code string
want bool
}{
{"NU1902", true}, // the transitive-vuln advisory that bites csharp-ls
{"NU1903", true},
{"NU1605", true},
{"NU1", true},
{"nu1902", true}, // prefix is case-insensitive
{"CS0246", false}, // real compiler error — must survive the filter
{"CS1591", false},
{"NU", false}, // no digits
{"NUGET", false}, // letters after NU
{"NU19A2", false}, // non-digit inside the number
{"1902", false},
{"N", false},
{"", false},
}
for _, c := range cases {
assert.Equalf(t, c.want, isNuGetAdvisoryCode(c.code), "isNuGetAdvisoryCode(%q)", c.code)
}
}

// TestDiagCodeString covers the wire-type normalisation: string codes pass
// through, json.Number renders, and anything else (incl. JSON-unmarshalled
// numeric float64) yields "" — sufficient because NU codes are always strings.
func TestDiagCodeString(t *testing.T) {
assert.Equal(t, "NU1902", diagCodeString("NU1902"))
assert.Equal(t, "1902", diagCodeString(json.Number("1902")))
assert.Equal(t, "", diagCodeString(float64(1902)))
assert.Equal(t, "", diagCodeString(1902))
assert.Equal(t, "", diagCodeString(nil))
}

// TestFilterCSharpAdvisoryDiags verifies the filter drops only NU#### NuGet
// advisories, preserves real CS#### diagnostics and their order, and returns
// the input untouched when there is nothing to drop.
func TestFilterCSharpAdvisoryDiags(t *testing.T) {
// No advisories → same slice handed back, unchanged.
clean := []Diagnostic{
{Code: "CS0246", Severity: DiagSeverityError, Message: "type or namespace not found"},
{Code: "CS1591", Severity: DiagSeverityWarning, Message: "missing XML comment"},
}
assert.Equal(t, clean, filterCSharpAdvisoryDiags(clean))

// Mixed: NU advisories dropped, CS diagnostics kept in original order.
mixed := []Diagnostic{
{Code: "NU1902", Severity: DiagSeverityError, Message: "package has a known vulnerability"},
{Code: "CS0246", Severity: DiagSeverityError, Message: "type or namespace not found"},
{Code: "NU1903", Severity: DiagSeverityWarning, Message: "high severity vulnerability"},
{Code: "CS0103", Severity: DiagSeverityError, Message: "name does not exist"},
}
got := filterCSharpAdvisoryDiags(mixed)
assert.Len(t, got, 2)
assert.Equal(t, "CS0246", got[0].Code)
assert.Equal(t, "CS0103", got[1].Code)

// All advisories → empty result.
assert.Empty(t, filterCSharpAdvisoryDiags([]Diagnostic{{Code: "NU1902"}, {Code: "NU1605"}}))

// Empty / nil input is safe.
assert.Empty(t, filterCSharpAdvisoryDiags(nil))
}

// TestServesCSharp checks the language-scoping guard used to confine the
// C#-specific behaviour to C# providers.
func TestServesCSharp(t *testing.T) {
assert.True(t, (&Provider{languages: []string{"csharp"}}).servesCSharp())
assert.True(t, (&Provider{languages: []string{"go", "csharp"}}).servesCSharp())
assert.False(t, (&Provider{languages: []string{"go"}}).servesCSharp())
assert.False(t, (&Provider{}).servesCSharp())
}

// TestCSharpDiagFilterEnabled confirms the filter is ON by default and only
// the explicit falsey values disable it.
func TestCSharpDiagFilterEnabled(t *testing.T) {
t.Setenv(CSharpDiagFilterEnv, "") // unset-equivalent → default ON
assert.True(t, csharpDiagFilterEnabled())

for _, off := range []string{"0", "off", "false", "none", "OFF", "False"} {
t.Setenv(CSharpDiagFilterEnv, off)
assert.Falsef(t, csharpDiagFilterEnabled(), "value %q should disable the filter", off)
}

t.Setenv(CSharpDiagFilterEnv, "1")
assert.True(t, csharpDiagFilterEnabled())
}

// TestCSharpPreRestoreEligible verifies the pre-restore gate: ON by default for
// a C# provider that is spawning, disabled only by an explicit falsey
// CSharpRestoreEnv, and never active for a non-C# provider or a passive attach
// (where the IDE owns restore).
func TestCSharpPreRestoreEligible(t *testing.T) {
csharp := func() *Provider { return &Provider{languages: []string{"csharp"}} }

t.Setenv(CSharpRestoreEnv, "") // unset-equivalent → default ON
assert.True(t, csharp().csharpPreRestoreEligible(), "on by default for a spawning C# provider")

for _, off := range []string{"0", "off", "false", "none", "OFF", "False"} {
t.Setenv(CSharpRestoreEnv, off)
assert.Falsef(t, csharp().csharpPreRestoreEligible(), "value %q should disable pre-restore", off)
}

t.Setenv(CSharpRestoreEnv, "1")
assert.True(t, csharp().csharpPreRestoreEligible(), "explicitly enabled + serves C# + spawning")

// Not a C# provider → never restore, even with restore enabled.
assert.False(t, (&Provider{languages: []string{"go"}}).csharpPreRestoreEligible())

// C# but passively attached (the IDE owns restore) → skip.
p := csharp()
p.connect = &ConnectSpec{}
assert.False(t, p.csharpPreRestoreEligible(), "passive-attach must not trigger restore")
}
Loading
Loading