a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
3603 lines
134 KiB
Go
3603 lines
134 KiB
Go
package lsp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/lspuri"
|
|
"github.com/zzet/gortex/internal/semantic"
|
|
)
|
|
|
|
// Provider uses an LSP server for on-demand semantic queries.
|
|
type Provider struct {
|
|
command string
|
|
args []string
|
|
// env carries extra KEY=VALUE entries for the server subprocess,
|
|
// from a .gortex.yaml override (e.g. JAVA_HOME for jdtls).
|
|
env []string
|
|
// workspaceFolders are additional roots advertised to the server's
|
|
// initialize request alongside the primary workspace root.
|
|
workspaceFolders []string
|
|
languages []string
|
|
daemon bool
|
|
maxParallel int
|
|
logger *zap.Logger
|
|
// excludeGlobs are user-configured path globs to skip for enrichment, on
|
|
// top of the built-in generated/vendored heuristic. Set by the router from
|
|
// config when the provider is spawned.
|
|
excludeGlobs []string
|
|
// sweepMode selects how much of the per-file hover / call-hierarchy sweep
|
|
// runs ("demand" default / "full" / "off"); see sweep.go. Set by the
|
|
// router from config when the provider is spawned. An empty value means
|
|
// the demand-gated default; the GORTEX_LSP_SWEEP env override wins over it.
|
|
sweepMode string
|
|
// spec is the ServerSpec this provider was built from (when the
|
|
// caller used NewProviderFromSpec). nil for legacy NewProvider
|
|
// invocations — those fall back to single-language routing.
|
|
spec *ServerSpec
|
|
|
|
// altInitOptions / altInitOptionsFunc carry the InitializationOptions
|
|
// of the alternative command that won on PATH in NewProviderFromSpec.
|
|
// effectiveInitializationOptions(spec) cannot see them because it keys
|
|
// on the spec's primary command; a resolved-alternative server (e.g.
|
|
// intelephense standing in for phpactor) sends these instead.
|
|
altInitOptions json.RawMessage
|
|
altInitOptionsFunc func(repoRoot string) json.RawMessage
|
|
|
|
client *Client
|
|
|
|
// sourceCache holds file contents read by openDocument so the
|
|
// per-symbol column-resolution lookups don't reread the file for
|
|
// every hover / references / implementation query. Keyed by
|
|
// absolute path. closeDocument drops a file's entry and
|
|
// resetForReconnect clears the whole map, so a long-lived
|
|
// interactive session — where hover / definition traffic keeps the
|
|
// provider's lastUsed fresh and the router never reaps it — does
|
|
// not retain every navigated file's bytes for the daemon's
|
|
// lifetime. Accessed lock-free (see getSource, which tolerates a
|
|
// miss by falling back to col=0): the interactive
|
|
// open→lookup→close sequence is serialized per file, so it needs
|
|
// no docMu.
|
|
sourceCache map[string][]byte
|
|
|
|
// docMu guards docVersions / openDocs / lastDiag so concurrent
|
|
// callers (LSP push notifications + MCP request goroutines) can
|
|
// share one client safely.
|
|
docMu sync.RWMutex
|
|
docVersions map[string]int // absPath → most-recent didOpen / didChange version
|
|
openDocs map[string]bool // absPath → already opened
|
|
lastDiag map[string][]Diagnostic // absPath → most recent diagnostics from publishDiagnostics
|
|
|
|
// diagWaitersMu guards diagWaiters which lets sync code wait for
|
|
// the next publishDiagnostics for a given file (e.g. fix-all
|
|
// loops re-collecting diagnostics after each apply).
|
|
diagWaitersMu sync.Mutex
|
|
diagWaiters map[string][]chan []Diagnostic
|
|
|
|
// diagHookMu guards diagHook — a single persistent subscriber the
|
|
// router (or any caller) can install to be notified on every
|
|
// publishDiagnostics. The hook MUST be non-blocking; it runs on
|
|
// the LSP client's message-pump goroutine.
|
|
diagHookMu sync.RWMutex
|
|
diagHook func(absPath string, diags []Diagnostic)
|
|
|
|
// capsMu guards caps and dynamicCaps. Read on every Supports()
|
|
// call (hot path), so use RWMutex — register/unregister bursts
|
|
// are rare relative to capability checks.
|
|
capsMu sync.RWMutex
|
|
// caps is the snapshot returned by the server's initialize reply
|
|
// — what the server statically supports. Set once per subprocess
|
|
// lifetime; reset to a zero value on respawn.
|
|
caps ServerCapabilities
|
|
// dynamicCaps holds capabilities the server announced lazily via
|
|
// client/registerCapability. Keyed by Registration.ID (the wire
|
|
// handle the server uses for unregisterCapability). Reset on
|
|
// every ensureClient — a fresh subprocess starts with an empty
|
|
// dynamic table and re-registers what it needs.
|
|
dynamicCaps map[string]Registration
|
|
|
|
// connect, when non-nil, switches ensureClient into passive-
|
|
// attach mode: the Provider dials the configured endpoint
|
|
// instead of spawning a subprocess. Reconnect on EOF retries
|
|
// the dial with exponential backoff; fallback to spawn happens
|
|
// only when connect.FallbackSpawn is true.
|
|
connect *ConnectSpec
|
|
// dialBackoff is the current backoff window between failed dial
|
|
// attempts. Doubles on each failure (capped at maxDialBackoff)
|
|
// and resets to dialBackoffStart on the first success.
|
|
dialBackoff time.Duration
|
|
|
|
// dialBackoffStart / maxDialBackoff are the per-instance bounds for
|
|
// the reconnect-with-backoff loop (Enrich hover recovery). They
|
|
// default to the package consts of the same name; tests pin them to
|
|
// tiny values to keep recovery fast. dialOrSpawn keeps using the
|
|
// package consts directly — only the mid-flight reconnect path reads
|
|
// these fields.
|
|
dialBackoffStart time.Duration
|
|
maxDialBackoff time.Duration
|
|
|
|
// reconnectMu serialises mid-flight reconnection attempts so that
|
|
// when many enrichment goroutines observe "LSP server exited" at the
|
|
// same instant, exactly one of them rebuilds the client while the
|
|
// others wait and then retry their hover against the fresh session.
|
|
reconnectMu sync.Mutex
|
|
// reconnectAttempts counts how many reconnect *cycles* ran across the
|
|
// whole Enrich pass. Surfaced in the final metrics log.
|
|
reconnectAttempts atomic.Int64
|
|
|
|
// connectOnce establishes a fresh client connection (one attempt, no
|
|
// backoff). Defaults to ensureClient. Injected in tests to swap in an
|
|
// in-memory piped client instead of spawning a subprocess.
|
|
connectOnce func(absRoot string) error
|
|
|
|
// reqStats counts the LSP request methods issued during the current
|
|
// enrichment pass. Reset at pass start and surfaced in the end-of-pass
|
|
// telemetry so the round-trip volume per method is observable.
|
|
reqStats requestStats
|
|
}
|
|
|
|
// requestStats holds one atomic counter per LSP request method the
|
|
// enrichment pass issues, so the pass-complete log can report where the
|
|
// round trips went.
|
|
type requestStats struct {
|
|
references atomic.Int64
|
|
implementations atomic.Int64
|
|
definitions atomic.Int64
|
|
hovers atomic.Int64
|
|
prepareCallHierarchy atomic.Int64
|
|
outgoingCalls atomic.Int64
|
|
incomingCalls atomic.Int64
|
|
prepareTypeHierarchy atomic.Int64
|
|
supertypes atomic.Int64
|
|
subtypes atomic.Int64
|
|
// incomingSkipped counts the callHierarchy/incomingCalls round trips the
|
|
// sweep declined to make because the declaration's callers are already
|
|
// recoverable from the outgoing side — a derived saving, not a request.
|
|
incomingSkipped atomic.Int64
|
|
}
|
|
|
|
// reset zeroes every counter at the start of an enrichment pass.
|
|
func (r *requestStats) reset() {
|
|
r.references.Store(0)
|
|
r.implementations.Store(0)
|
|
r.definitions.Store(0)
|
|
r.hovers.Store(0)
|
|
r.prepareCallHierarchy.Store(0)
|
|
r.outgoingCalls.Store(0)
|
|
r.incomingCalls.Store(0)
|
|
r.prepareTypeHierarchy.Store(0)
|
|
r.supertypes.Store(0)
|
|
r.subtypes.Store(0)
|
|
r.incomingSkipped.Store(0)
|
|
}
|
|
|
|
// Dial-retry constants for passive-attach reconnect. The window
|
|
// doubles on each failure and tops out at maxDialBackoff, then a
|
|
// successful dial resets it. Test fixtures can pin these via the
|
|
// helpers in the test file; production code uses the defaults.
|
|
const (
|
|
dialBackoffStart = 100 * time.Millisecond
|
|
maxDialBackoff = 30 * time.Second
|
|
)
|
|
|
|
// NewProvider creates an LSP provider.
|
|
func NewProvider(command string, args []string, languages []string, daemon bool, maxParallel int, logger *zap.Logger) *Provider {
|
|
if maxParallel <= 0 {
|
|
maxParallel = 10
|
|
}
|
|
return &Provider{
|
|
command: command,
|
|
args: args,
|
|
languages: languages,
|
|
daemon: daemon,
|
|
maxParallel: maxParallel,
|
|
logger: logger,
|
|
docVersions: map[string]int{},
|
|
openDocs: map[string]bool{},
|
|
lastDiag: map[string][]Diagnostic{},
|
|
diagWaiters: map[string][]chan []Diagnostic{},
|
|
dynamicCaps: map[string]Registration{},
|
|
dialBackoffStart: dialBackoffStart,
|
|
maxDialBackoff: maxDialBackoff,
|
|
}
|
|
}
|
|
|
|
// NewProviderFromSpec builds a Provider directly from a ServerSpec.
|
|
// Mostly equivalent to NewProvider but lets the runtime router resolve
|
|
// the right `languageId` per file extension and pick the first
|
|
// available command from the spec's alternatives.
|
|
func NewProviderFromSpec(spec *ServerSpec, logger *zap.Logger) *Provider {
|
|
cmd := spec.Command
|
|
args := spec.Args
|
|
var altInitOptions json.RawMessage
|
|
var altInitOptionsFunc func(repoRoot string) json.RawMessage
|
|
if _, err := exec.LookPath(cmd); err != nil {
|
|
for _, alt := range spec.AlternativeCommands {
|
|
if _, err := exec.LookPath(alt.Command); err == nil {
|
|
cmd = alt.Command
|
|
args = alt.Args
|
|
altInitOptions = alt.InitializationOptions
|
|
altInitOptionsFunc = alt.InitOptionsFunc
|
|
break
|
|
}
|
|
}
|
|
}
|
|
maxParallel := spec.MaxParallel
|
|
if maxParallel <= 0 {
|
|
maxParallel = 10
|
|
}
|
|
p := &Provider{
|
|
command: cmd,
|
|
args: args,
|
|
env: spec.Env,
|
|
languages: spec.Languages,
|
|
daemon: spec.Daemon,
|
|
maxParallel: maxParallel,
|
|
logger: logger,
|
|
spec: spec,
|
|
docVersions: map[string]int{},
|
|
openDocs: map[string]bool{},
|
|
lastDiag: map[string][]Diagnostic{},
|
|
diagWaiters: map[string][]chan []Diagnostic{},
|
|
dynamicCaps: map[string]Registration{},
|
|
connect: spec.Connect,
|
|
altInitOptions: altInitOptions,
|
|
altInitOptionsFunc: altInitOptionsFunc,
|
|
dialBackoff: dialBackoffStart,
|
|
dialBackoffStart: dialBackoffStart,
|
|
maxDialBackoff: maxDialBackoff,
|
|
}
|
|
return p
|
|
}
|
|
|
|
// effectiveInitOptions resolves the initializationOptions to send in this
|
|
// provider's initialize request. When the provider was built from a spec
|
|
// whose primary command was absent and an alternative won on PATH, that
|
|
// alternative's options take precedence over the spec-level blob (which is
|
|
// keyed to the primary command and cannot see the resolved alternative).
|
|
// InitOptionsFunc is consulted first so an alternative can root a per-repo
|
|
// cache path under Gortex's cache home using workspaceRoot.
|
|
func (p *Provider) effectiveInitOptions(workspaceRoot string) json.RawMessage {
|
|
if p.altInitOptionsFunc != nil {
|
|
if opts := p.altInitOptionsFunc(workspaceRoot); len(opts) > 0 {
|
|
return opts
|
|
}
|
|
}
|
|
if len(p.altInitOptions) > 0 {
|
|
return p.altInitOptions
|
|
}
|
|
return effectiveInitializationOptions(p.spec)
|
|
}
|
|
|
|
func (p *Provider) Name() string { return "lsp-" + p.command }
|
|
func (p *Provider) Languages() []string { return p.languages }
|
|
|
|
func (p *Provider) Available() bool {
|
|
_, err := exec.LookPath(p.command)
|
|
return err == nil
|
|
}
|
|
|
|
func (p *Provider) Close() error {
|
|
if p.client != nil {
|
|
return p.client.Shutdown()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nodeRelPath strips a node's own RepoPrefix from its FilePath so the
|
|
// result joins cleanly under repoRoot, which carries no prefix. Without
|
|
// it a multi-repo node FilePath like "gortex/bench/x.rb" joined onto
|
|
// repoRoot ".../gortex" doubles the prefix (".../gortex/gortex/bench/x.rb")
|
|
// and every os.ReadFile / didOpen fails with ENOTDIR.
|
|
func nodeRelPath(n *graph.Node) string {
|
|
if n.RepoPrefix != "" {
|
|
return strings.TrimPrefix(n.FilePath, n.RepoPrefix+"/")
|
|
}
|
|
return n.FilePath
|
|
}
|
|
|
|
// enrichNodeHasUnresolvedDemand reports whether a callable declaration still
|
|
// has unresolved same-name call candidates in the graph — `unresolved::*.<name>`
|
|
// edges the resolver never bound. Such declarations are the ones an LSP
|
|
// references pass can actually connect, so the add-phase enriches them first.
|
|
// Unresolved call stubs are indexed by their target string, so this is a
|
|
// single reverse-edge lookup, not a scan.
|
|
func enrichNodeHasUnresolvedDemand(g graph.Store, n *graph.Node) bool {
|
|
if n == nil || n.Name == "" {
|
|
return false
|
|
}
|
|
if n.Kind != graph.KindMethod && n.Kind != graph.KindFunction {
|
|
return false
|
|
}
|
|
return len(g.GetInEdges(graph.UnresolvedMarker+"*."+n.Name)) > 0
|
|
}
|
|
|
|
// enrichNodeIsDispatchRelevant reports whether a declaration's super/subtype
|
|
// hierarchy the per-file sweep must interrogate: a type or interface whose
|
|
// extends / supertype / subtype edges the AST extractor commonly misses (they
|
|
// are cross-file or resolved dynamically). Such declarations never contribute
|
|
// unresolved-call demand — enrichNodeHasUnresolvedDemand only counts callables —
|
|
// so a file whose only enrichable work is a type hierarchy would score zero
|
|
// demand and be skipped under the demand default. Marking it dispatch-relevant
|
|
// keeps that file in the sweep so its hierarchy edges are still recovered.
|
|
func enrichNodeIsDispatchRelevant(n *graph.Node) bool {
|
|
if n == nil {
|
|
return false
|
|
}
|
|
return n.Kind == graph.KindType || n.Kind == graph.KindInterface
|
|
}
|
|
|
|
// enrichCallableIsDispatchRelevant reports whether a function or method takes
|
|
// part in dynamic dispatch, so its incoming callers name concrete targets the
|
|
// outgoing side of the sweep cannot reach. Every intra-repo static call is
|
|
// recoverable from its caller's outgoing hop — collected for every caller by
|
|
// the file sweep — so a declaration's incoming callers only add signal when a
|
|
// call to it dispatches through another declaration: a call through an
|
|
// interface / abstract member lands the caller's outgoing edge on that member,
|
|
// leaving the concrete implementation's real call sites visible only from its
|
|
// incoming side. A declaration qualifies when it is itself an abstract /
|
|
// interface / virtual member, when it overrides (or is overridden by) another
|
|
// declaration, or when its declaring type implements or extends another type.
|
|
func enrichCallableIsDispatchRelevant(g graph.Store, n *graph.Node) bool {
|
|
if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) {
|
|
return false
|
|
}
|
|
if isAbstractMarked(n) {
|
|
return true
|
|
}
|
|
var parentType string
|
|
for _, e := range g.GetOutEdges(n.ID) {
|
|
switch e.Kind {
|
|
case graph.EdgeOverrides:
|
|
return true
|
|
case graph.EdgeMemberOf:
|
|
parentType = e.To
|
|
}
|
|
}
|
|
for _, e := range g.GetInEdges(n.ID) {
|
|
if e.Kind == graph.EdgeOverrides {
|
|
return true
|
|
}
|
|
}
|
|
if parentType == "" {
|
|
return false
|
|
}
|
|
// The declaring type implementing / extending another type makes its
|
|
// methods dispatch targets: a call through the interface or base type
|
|
// binds elsewhere, so this method's callers surface only via incoming.
|
|
for _, e := range g.GetOutEdges(parentType) {
|
|
if e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeExtends {
|
|
return true
|
|
}
|
|
}
|
|
for _, e := range g.GetInEdges(parentType) {
|
|
if e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeExtends {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// nodeHasSemanticType reports whether a node already carries a non-empty
|
|
// semantic_type stamp from an earlier enrichment. The per-file sweep skips
|
|
// re-hovering such a node — the hover would only re-derive the identical type
|
|
// string — which is what keeps a dispatch-relevant file (swept for its call /
|
|
// type-hierarchy edges) from re-paying the whole-file hover cost on a warm
|
|
// restart, where every node reloads with its prior stamp.
|
|
func nodeHasSemanticType(n *graph.Node) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
s, ok := n.Meta["semantic_type"].(string)
|
|
return ok && s != ""
|
|
}
|
|
|
|
// nodeAlreadyStamped reports whether a prior enrichment pass already wrote a
|
|
// semantic type onto the node. semantic.EnrichNodeMeta stamps semantic_type
|
|
// (the load-bearing key) alongside semantic_source, and the stamp round-trips
|
|
// through the store, so it persists across daemon restarts. A stamped node is
|
|
// already covered — re-hovering it is redundant LSP work. An edited file
|
|
// re-parses into fresh node objects with no Meta merge, so its symbols lose
|
|
// the stamp and are re-selected naturally on the next pass.
|
|
func nodeAlreadyStamped(n *graph.Node) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
v, ok := n.Meta["semantic_type"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if s, isStr := v.(string); isStr {
|
|
return s != ""
|
|
}
|
|
return v != nil
|
|
}
|
|
|
|
// scopedPath re-attaches repoPrefix to a repo-relative path the language
|
|
// server handed back: uriToPath returns repo-relative, but graph node
|
|
// FilePaths are prefixed, so node lookups must re-prefix to match in a
|
|
// multi-repo graph.
|
|
func scopedPath(repoPrefix, rel string) string {
|
|
if repoPrefix == "" || rel == "" {
|
|
return rel
|
|
}
|
|
return repoPrefix + "/" + rel
|
|
}
|
|
|
|
// edgeSiteRelPath returns the repo-relative path of the file holding
|
|
// the edge's recorded call site, falling back to the caller node's
|
|
// path when the edge carries none.
|
|
func edgeSiteRelPath(e *graph.Edge, repoPrefix, callerRel string) string {
|
|
if e.FilePath == "" {
|
|
return callerRel
|
|
}
|
|
if repoPrefix != "" {
|
|
return strings.TrimPrefix(e.FilePath, repoPrefix+"/")
|
|
}
|
|
return e.FilePath
|
|
}
|
|
|
|
// rebindTargetAcceptable reports whether a node kind is a sensible
|
|
// rebind target for a reference-shaped edge. Files, imports and params
|
|
// are containers/positions, never the declaration a call site binds to.
|
|
func rebindTargetAcceptable(k graph.NodeKind) bool {
|
|
switch k {
|
|
case graph.KindFile, graph.KindImport, graph.KindParam:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// edgeExistsAt reports whether an edge (from, to, kind) already exists
|
|
// at the given site line — used to avoid minting a duplicate when a
|
|
// rebind would land exactly on an edge another pass already recorded.
|
|
func edgeExistsAt(g graph.Store, from, to string, kind graph.EdgeKind, line int) bool {
|
|
for _, e := range g.GetOutEdges(from) {
|
|
if e.To == to && e.Kind == kind && e.Line == line {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// definitionNodeAtSite asks the language server which declaration the
|
|
// identifier `name` at (siteRel, siteLine) resolves to, and maps the
|
|
// answer back to a graph node by declaration file + line + name.
|
|
//
|
|
// Returns (node, true) when the server answered and the definition
|
|
// landed on a known node; (nil, true) when the server answered but no
|
|
// graph node anchors there (external / builtin / unindexed target);
|
|
// (nil, false) when there is no verdict (identifier not on the line,
|
|
// open/transport failure, empty response) — callers must not draw
|
|
// conclusions from a no-verdict.
|
|
//
|
|
// cache memoises verdicts per (file, line, name) within one enrichment
|
|
// pass; multiple ambiguous edges can share a call site.
|
|
//
|
|
// content is the site file's bytes, already opened on the server and read
|
|
// from disk by the caller (via the shared document session). A nil content
|
|
// yields a no-verdict, matching the pre-session behaviour when the site
|
|
// file could not be opened.
|
|
func (p *Provider) definitionNodeAtSite(g graph.Store, repoPrefix, absRoot, siteRel string, siteLine int, name string, content []byte, cache map[string]*graph.Node) (*graph.Node, bool) {
|
|
if siteRel == "" || siteLine <= 0 || name == "" {
|
|
return nil, false
|
|
}
|
|
key := siteRel + "\x00" + strconv.Itoa(siteLine) + "\x00" + name
|
|
if cached, ok := cache[key]; ok {
|
|
return cached, true
|
|
}
|
|
col, found := identifierColumnStrict(content, siteLine, name)
|
|
if !found {
|
|
// The identifier is not on the recorded line — a definition
|
|
// request would return junk for whatever token sits there.
|
|
return nil, false
|
|
}
|
|
locs, err := p.FindDefinition(absRoot, siteRel, siteLine-1, col, lspCallTimeout())
|
|
if err != nil || len(locs) == 0 {
|
|
return nil, false
|
|
}
|
|
defPath := uriToPath(locs[0].URI, absRoot)
|
|
if defPath == "" {
|
|
// Definition outside the workspace (stdlib, site-packages…).
|
|
cache[key] = nil
|
|
return nil, true
|
|
}
|
|
node := findDeclarationNode(g, scopedPath(repoPrefix, defPath), locs[0].Range.Start.Line+1, name)
|
|
cache[key] = node
|
|
return node, true
|
|
}
|
|
|
|
// findDeclarationNode locates the graph node whose declaration matches
|
|
// (filePath, oneBasedLine, name). Exact StartLine match wins; a ±1
|
|
// slack covers servers that anchor the definition on the identifier
|
|
// line of a multi-line declaration header. The name must match — that
|
|
// is the identity check this lookup exists for.
|
|
func findDeclarationNode(g graph.Store, filePath string, oneBasedLine int, name string) *graph.Node {
|
|
var near *graph.Node
|
|
for _, n := range g.GetFileNodes(filePath) {
|
|
if n == nil || n.Name != name {
|
|
continue
|
|
}
|
|
if n.Kind == graph.KindFile || n.Kind == graph.KindImport || n.Kind == graph.KindParam {
|
|
continue
|
|
}
|
|
if n.StartLine == oneBasedLine {
|
|
return n
|
|
}
|
|
if near == nil && n.StartLine >= oneBasedLine-1 && n.StartLine <= oneBasedLine+1 {
|
|
near = n
|
|
}
|
|
}
|
|
return near
|
|
}
|
|
|
|
// Enrich runs the full LSP enrichment pass for a single-repo (un-
|
|
// prefixed) graph. It delegates to EnrichRepoContext with an empty prefix.
|
|
func (p *Provider) Enrich(g graph.Store, repoRoot string) (*semantic.EnrichResult, error) {
|
|
return p.EnrichRepoContext(context.Background(), g, "", repoRoot, nil)
|
|
}
|
|
|
|
// EnrichRepo runs the full LSP enrichment pass with no cancellation
|
|
// bound. Kept so the provider still satisfies semantic.RepoScopedProvider
|
|
// for callers that don't thread a context.
|
|
func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*semantic.EnrichResult, error) {
|
|
return p.EnrichRepoContext(context.Background(), g, repoPrefix, repoRoot, nil)
|
|
}
|
|
|
|
// EnrichRepoContext runs the full LSP enrichment pass over the nodes that
|
|
// belong to repoPrefix (the multi-repo scope key; "" for a single-repo /
|
|
// in-memory graph). Scoping the node/edge selection to one repo stops a
|
|
// multi-repo graph from driving this repo's language server with another
|
|
// repo's files, and lets each node's on-disk path resolve by stripping
|
|
// its own RepoPrefix.
|
|
//
|
|
// The language server is spawned lazily: if the repo has no AMBIGUOUS
|
|
// (sub-1.0-confidence) edge of this provider's language there is nothing
|
|
// for the server to confirm or refute, so the pass returns before
|
|
// starting it. This is what keeps a warm restart — where the snapshot is
|
|
// already fully resolved — from paying a full server spin-up plus a whole
|
|
// hover / call-hierarchy sweep per language for zero enrichment.
|
|
//
|
|
// Work is ordered by accuracy value and lands INCREMENTALLY: the
|
|
// interface-implementation and ambiguous-edge-confirmation passes (the
|
|
// edges that decide graph tiers) run first and commit per item; the
|
|
// per-file sweep runs call/type hierarchy before hover within each file
|
|
// and flushes each file's stamps + hops into the graph as soon as the
|
|
// file completes. When ctx is cancelled (the Manager's per-repo
|
|
// deadline) the pass stops scheduling new work, keeps everything already
|
|
// flushed, marks the result Partial, and returns — completed work is
|
|
// never discarded and no writer goroutine outlives the pass.
|
|
//
|
|
// deadline (may be nil) sizes the pass's context bound LAZILY, from the
|
|
// count of hover candidates left after already-stamped nodes are skipped.
|
|
// A warm restart where most nodes are already stamped lands a small budget;
|
|
// a cold repo (nothing stamped) keeps the full size-scaled headroom. The
|
|
// incoming ctx already carries the Manager's generous outer ceiling; the
|
|
// derived bound only ever narrows it.
|
|
func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPrefix, repoRoot string, deadline semantic.EnrichDeadlinePolicy) (*semantic.EnrichResult, error) {
|
|
start := time.Now()
|
|
|
|
absRoot, err := filepath.Abs(repoRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &semantic.EnrichResult{
|
|
Provider: p.Name(),
|
|
Language: p.languages[0],
|
|
}
|
|
|
|
// Gather this repo's nodes via the indexed repo-scoped scan, NOT a
|
|
// whole-graph AllNodes / AllEdges walk: on a disk backend the latter is
|
|
// O(graph) per provider per repo (a whole-graph AllEdges plus a point
|
|
// GetNode per edge), which dominated fresh-index warmup. Split into two
|
|
// views from one scan:
|
|
// - langNodes: SYMBOL nodes (file/import excluded) — drives the symbol
|
|
// count, the per-file fan-out, and the interface-implementation pass.
|
|
// - langAllByID: every repo+language node id INCLUDING file/import — the
|
|
// original ambiguous-edge target scan matched any repo+language source
|
|
// node (file/import edges like EdgeImports included), so the
|
|
// references-confirm pass must keep matching those too.
|
|
//
|
|
// repoNodes is fetched via repoScopedNodesLight: on a backend with the
|
|
// optional LightNodeReader fast path (sqlite), the meta blob is never
|
|
// decoded for the repo's already-stamped majority. langAllByID only
|
|
// ever needs structural fields (file path, kind, position) for the
|
|
// confirm pass below, so it's built directly from whatever repoNodes
|
|
// holds. langNodes' candidates, in contrast, get round-tripped through
|
|
// AddBatch once hovered (see flushFile), so on a light scan a candidate
|
|
// is never added there directly — its ID is queued and the FULL node
|
|
// (with any non-promoted meta intact) is re-fetched afterward.
|
|
repoNodes, lightScan := p.repoScopedNodesLight(g, repoPrefix)
|
|
langAllByID := make(map[string]*graph.Node, len(repoNodes))
|
|
langNodes := make([]*graph.Node, 0, len(repoNodes))
|
|
var candidateIDs []string
|
|
// Count symbol nodes a prior pass already stamped with a semantic type.
|
|
// Stamps persist in node Meta across restarts, so on a warm restart of an
|
|
// unchanged repo — or a changed repo where only a few files moved — most
|
|
// nodes are already covered and re-hovering them is redundant LSP work.
|
|
skippedAlreadyStamped := 0
|
|
for _, n := range repoNodes {
|
|
if n.RepoPrefix != repoPrefix || !p.languageMatches(n.Language) {
|
|
continue
|
|
}
|
|
// Skip machine-generated / vendored files (e.g. tree-sitter's generated
|
|
// parser.c) so the language server never opens or indexes them — that
|
|
// indexing is by far the slowest part of a fresh index for zero value.
|
|
if semantic.IsLowValueForEnrichment(n.FilePath, p.excludeGlobs) {
|
|
continue
|
|
}
|
|
langAllByID[n.ID] = n
|
|
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
|
|
continue
|
|
}
|
|
// Skip symbols a prior enrichment pass already stamped — hover would
|
|
// only re-derive the same type. An edited file re-parses into fresh
|
|
// nodes with no Meta, so its symbols lose the stamp and re-enter here.
|
|
if nodeAlreadyStamped(n) {
|
|
skippedAlreadyStamped++
|
|
continue
|
|
}
|
|
if lightScan {
|
|
candidateIDs = append(candidateIDs, n.ID)
|
|
continue
|
|
}
|
|
langNodes = append(langNodes, n)
|
|
}
|
|
if len(candidateIDs) > 0 {
|
|
full := g.GetNodesByIDs(candidateIDs)
|
|
for _, id := range candidateIDs {
|
|
if n := full[id]; n != nil {
|
|
langNodes = append(langNodes, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect AMBIGUOUS edges (confidence < 1.0) whose source is one of this
|
|
// repo's language nodes — the references pass below confirms / refutes
|
|
// them. The indexed GetRepoEdges scan + the id-set replaces the AllEdges
|
|
// walk with a per-edge GetNode. (enrichTarget is package-scoped so the
|
|
// confirm-pass grouping / matching helpers can take it.)
|
|
var targets []enrichTarget
|
|
for _, e := range p.repoScopedEdges(g, repoPrefix) {
|
|
if e.Confidence >= 1.0 {
|
|
continue
|
|
}
|
|
// Skip structural-containment edges (member_of, defines, contains,
|
|
// param_of, imports, captures): they anchor no use site a reference
|
|
// lookup can adjudicate, so confirming them wastes a round-trip and can
|
|
// feed a correct edge into the definition-rebind fallback and mutate its
|
|
// target. Use-site, type-position and dataflow edges stay confirmable.
|
|
// See confirmableEdgeKind.
|
|
if !confirmableEdgeKind(e.Kind) {
|
|
continue
|
|
}
|
|
if from, ok := langAllByID[e.From]; ok {
|
|
targets = append(targets, enrichTarget{node: from, edge: e})
|
|
}
|
|
}
|
|
|
|
// Lazy server spawn: spin up only when there is something to do — at least
|
|
// one symbol node of the language, OR an ambiguous edge to confirm (the
|
|
// same condition the original whole-graph gate applied). When the repo has
|
|
// neither (a Swift / TS server scoped to a Go repo, or a language whose
|
|
// nodes all live in another repo) return without starting the server — this
|
|
// stops a per-language LSP spin-up for zero enrichment.
|
|
if len(langNodes) == 0 && len(targets) == 0 {
|
|
if p.logger != nil {
|
|
p.logger.Debug("LSP enrich: skipped, repo has no nodes for language",
|
|
zap.String("provider", p.Name()),
|
|
zap.String("repo_prefix", repoPrefix),
|
|
)
|
|
}
|
|
result.DurationMs = time.Since(start).Milliseconds()
|
|
return result, nil
|
|
}
|
|
|
|
// Project-readiness preflight: a server whose workspace lacks the project
|
|
// setup it needs (node_modules for tsserver) resolves nothing — every
|
|
// import / cross-file request returns "no package metadata" — yet still
|
|
// pays a full index + per-file hover / call-hierarchy sweep for minutes.
|
|
// Skip the pass entirely; the tree-sitter floor still covers these files,
|
|
// and the resolver's static edges are untouched. This is the general form
|
|
// of the compile-database degrade below, for a missing dependency tree
|
|
// rather than a missing compilation database.
|
|
if p.spec != nil && p.spec.ProjectReady != nil {
|
|
if ready, remediation := p.spec.ProjectReady(absRoot); !ready {
|
|
if p.logger != nil {
|
|
p.logger.Warn("LSP enrich: skipped, project not analyzable",
|
|
zap.String("provider", p.Name()),
|
|
zap.String("repo_prefix", repoPrefix),
|
|
zap.String("remediation", remediation),
|
|
)
|
|
}
|
|
result.DegradedReason = remediation
|
|
result.DurationMs = time.Since(start).Milliseconds()
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
// Compile-database preflight: a server that needs a compilation database
|
|
// (clangd) but has none rebuilds a full fallback AST on every didOpen, so
|
|
// the hover / hierarchy sweep churns for little signal and a directly
|
|
// opened header becomes a standalone TU. Degrade to reference confirmation
|
|
// — the confirm / rebind passes work inside the fallback TU on fallback
|
|
// flags — and skip the sweep, the interface pass, and header files.
|
|
degraded := p.spec != nil && p.spec.NeedsCompileDB && !hasCompileDB(absRoot)
|
|
var degradedSkipFile func(rel string) bool
|
|
if degraded {
|
|
degradedSkipFile = isCXXHeaderFile
|
|
result.Degraded = true
|
|
result.DegradedReason = "no compilation database found; enrichment limited to reference confirmation (hover, hierarchy, and header translation units skipped)"
|
|
if p.logger != nil {
|
|
p.logger.Warn("LSP enrich: degrading to reference confirmation, compilation database missing",
|
|
zap.String("provider", p.Name()),
|
|
zap.String("repo_prefix", repoPrefix),
|
|
zap.String("remediation", "generate compile_commands.json (cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON, bear -- make, or meson), then re-index"),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Start or reuse the client now that there is work to do.
|
|
if err := p.ensureClient(absRoot); err != nil {
|
|
return nil, fmt.Errorf("start LSP server: %w", err)
|
|
}
|
|
// Default the reconnect seam to the real ensureClient unless a test
|
|
// injected its own (in-memory piped client). Set once per pass.
|
|
if p.connectOnce == nil {
|
|
p.connectOnce = p.ensureClient
|
|
}
|
|
// Reset the cross-pass reconnect counter so the metrics log reflects
|
|
// only this Enrich invocation.
|
|
p.reconnectAttempts.Store(0)
|
|
// Reset the per-method request counters so the pass-complete log
|
|
// reports only this invocation's round trips.
|
|
p.reqStats.reset()
|
|
|
|
// One document session shared by every phase below: a file didOpen'd
|
|
// by the interface pass stays warm for the confirm pass and the sweep
|
|
// instead of being reopened, and every open is paired with a didClose
|
|
// (evicted under cap, or by closeAll here at pass end).
|
|
session := newDocSession(p)
|
|
defer session.closeAll()
|
|
|
|
// Total symbols scoped to repo + language. langNodes now holds only the
|
|
// symbols this pass will hover (file / import nodes and already-stamped
|
|
// nodes excluded); HoverCandidates exposes that post-filter count for
|
|
// deadline budgeting. SymbolsTotal keeps whole-repo meaning by re-adding
|
|
// the already-stamped symbols, which are pre-seeded into SymbolsCovered
|
|
// since their semantic type is already in the graph.
|
|
result.HoverCandidates = len(langNodes)
|
|
result.SymbolsTotal = len(langNodes) + skippedAlreadyStamped
|
|
result.SymbolsCovered = skippedAlreadyStamped
|
|
|
|
// Lazy per-repo deadline: now that candidate selection is done, size the
|
|
// window on the count of symbols this pass will actually hover — the
|
|
// already-stamped nodes were skipped above, so a warm restart with few
|
|
// unstamped nodes lands a small budget while a cold repo keeps full
|
|
// headroom. Only ever narrows the Manager's outer ceiling already on ctx;
|
|
// BudgetSeconds records the derived value for the status surface.
|
|
if deadline != nil {
|
|
if d := deadline(len(langNodes)); d > 0 {
|
|
result.BudgetSeconds = d.Seconds()
|
|
var cancelBudget context.CancelFunc
|
|
ctx, cancelBudget = context.WithTimeout(ctx, d)
|
|
defer cancelBudget()
|
|
}
|
|
}
|
|
|
|
// The graph-mutation blocks in this pass serialise on the backend
|
|
// resolve mutex (the same lock every other edge-mutating pass holds)
|
|
// so this pass can run concurrently with other repos' enrichment.
|
|
// Only the in-memory mutations are locked — the LSP I/O stays outside
|
|
// the lock so concurrent language servers still overlap.
|
|
rmu := g.ResolveMutex()
|
|
|
|
// Targeted edge work runs FIRST — before the whole-repo per-file
|
|
// sweep — because confirmed / added edges (implements, calls) decide
|
|
// graph tiers and find_usages accuracy, while hover only stamps type
|
|
// strings. Each item commits to the graph as soon as it resolves, so
|
|
// a deadline cut loses only the un-visited remainder.
|
|
//
|
|
// Deadline budgeting: the reference-confirm pass is round-trip bound and
|
|
// can, on a medium repo, consume the entire per-repo deadline before the
|
|
// hover / hierarchy add phase runs at all — leaving edges_added stuck at 0.
|
|
// targetedCtx caps the targeted-edge passes (implementations + confirm) at
|
|
// a fraction of the window, reserving the remainder for the sweep so both
|
|
// phases make progress. With no deadline (tests) it is the parent context.
|
|
// A degraded pass skips the sweep entirely, so there is nothing to reserve
|
|
// for — give the confirm pass the whole window.
|
|
targetedCtx := ctx
|
|
if dl, ok := ctx.Deadline(); ok && !degraded {
|
|
window := dl.Sub(start)
|
|
reserve := time.Duration(float64(window) * enrichSweepReserveFraction)
|
|
if reserve > 0 && reserve < window {
|
|
var cancelTargeted context.CancelFunc
|
|
targetedCtx, cancelTargeted = context.WithDeadline(ctx, dl.Add(-reserve))
|
|
defer cancelTargeted()
|
|
}
|
|
}
|
|
|
|
// Query implementations for interface nodes. A degraded pass skips this:
|
|
// the query opens each interface's file, and a database-less clangd cannot
|
|
// resolve implementations across translation units regardless.
|
|
for _, n := range langNodes {
|
|
if degraded {
|
|
break
|
|
}
|
|
if targetedCtx.Err() != nil {
|
|
break
|
|
}
|
|
if n.Kind != graph.KindInterface {
|
|
continue
|
|
}
|
|
line, ok := lspLine(n)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
rel := nodeRelPath(n)
|
|
if !p.servesFile(rel) {
|
|
continue // never open a file this server can't compile
|
|
}
|
|
// Open this interface's file through the shared session: the query
|
|
// runs while it is pinned, and release leaves it warm in the LRU so
|
|
// the confirm pass and sweep reuse it instead of reopening.
|
|
content, release, err := session.acquire(p.client, filepath.Join(absRoot, rel))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
col := identifierColumn(content, n.StartLine, n.Name)
|
|
impls, err := p.findImplementations(absRoot, rel, line, col)
|
|
release()
|
|
if err != nil || len(impls) == 0 {
|
|
continue
|
|
}
|
|
|
|
rmu.Lock()
|
|
for _, loc := range impls {
|
|
implPath := uriToPath(loc.URI, absRoot)
|
|
if implPath == "" {
|
|
continue
|
|
}
|
|
implNode := semantic.MatchNodeByFileLine(g, scopedPath(repoPrefix, implPath), loc.Range.Start.Line+1)
|
|
if implNode == nil {
|
|
continue
|
|
}
|
|
|
|
existing := semantic.FindMatchingEdge(g, implNode.ID, n.ID, graph.EdgeImplements)
|
|
if existing != nil {
|
|
if existing.Confidence < 1.0 {
|
|
semantic.ConfirmEdge(existing, p.Name())
|
|
semantic.PersistEdge(g, existing)
|
|
result.EdgesConfirmed++
|
|
}
|
|
} else {
|
|
semantic.AddSemanticEdge(g, implNode.ID, n.ID, graph.EdgeImplements,
|
|
implNode.FilePath, implNode.StartLine, p.Name())
|
|
result.EdgesAdded++
|
|
}
|
|
}
|
|
rmu.Unlock()
|
|
}
|
|
|
|
// Query references for AMBIGUOUS edges to confirm/refute. Promotion
|
|
// to the lsp tier is identity-anchored: the server's evidence must
|
|
// name the EDGE'S OWN call site, not merely fall somewhere inside
|
|
// the caller's body. The old span-containment check promoted a
|
|
// heuristically-misbound edge whenever the caller referenced BOTH
|
|
// same-named declarations (test exercising sync Client.stream and
|
|
// async AsyncClient.stream in one function): one genuine reference
|
|
// of the wrong target inside the caller's span rubber-stamped the
|
|
// edge bound at the OTHER member's line — a compiler-grade tier
|
|
// serving the wrong declaration.
|
|
//
|
|
// The sweep fans out across maxParallel, grouped by the referent's file
|
|
// so each file is opened once (per-goroutine, via enrichOpenDoc) and
|
|
// serves every target sharing it — turning the ~7 edges/s sequential
|
|
// round-trip loop into maxParallel-wide throughput. The definition-rebind
|
|
// fallback opens arbitrary call-site files, so it runs serially afterward
|
|
// over the targets the sweep left unconfirmed, keeping document open/close
|
|
// from overlapping across goroutines.
|
|
confirmGroups := p.groupConfirmTargets(g, targets, degradedSkipFile)
|
|
var confirmMu sync.Mutex
|
|
var fallback []enrichTarget
|
|
{
|
|
sem := make(chan struct{}, p.maxParallel)
|
|
var wg sync.WaitGroup
|
|
for _, grp := range confirmGroups {
|
|
if targetedCtx.Err() != nil {
|
|
break
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(grp *confirmGroup) {
|
|
defer func() {
|
|
<-sem
|
|
wg.Done()
|
|
}()
|
|
if targetedCtx.Err() != nil {
|
|
return
|
|
}
|
|
absPath := filepath.Join(absRoot, grp.rel)
|
|
// The file is unique to this group, so no two goroutines open
|
|
// it; the session pins it for the group and leaves it warm for
|
|
// a later phase that shares the file.
|
|
content, release, err := session.acquire(p.client, absPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer release()
|
|
// findReferences is positioned on the TARGET declaration, not the
|
|
// call site, so every ambiguous edge in this group that points at
|
|
// the same referent asks the server for the identical reference
|
|
// list — measured ~9x fan-in for calls, ~8x for references, since
|
|
// an overloaded / hot declaration draws many candidate edges.
|
|
// confirmRefMatchesSite still filters that shared list per edge,
|
|
// so caching it by target node turns N identical round-trips into
|
|
// one with byte-identical confirm/refute decisions. A target's
|
|
// file is unique to one group, so every edge to it lands here —
|
|
// the per-group cache captures all of its redundancy. ok=false
|
|
// records a server error so repeat targets skip exactly as the
|
|
// un-cached path did (error → skip, no fallback).
|
|
type cachedRefs struct {
|
|
refs []Location
|
|
ok bool
|
|
}
|
|
refsByTarget := make(map[string]cachedRefs, len(grp.targets))
|
|
for _, t := range grp.targets {
|
|
if targetedCtx.Err() != nil {
|
|
return
|
|
}
|
|
toNode := g.GetNode(t.edge.To)
|
|
if toNode == nil {
|
|
continue
|
|
}
|
|
line, ok := lspLine(toNode)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cr, seen := refsByTarget[t.edge.To]
|
|
if !seen {
|
|
col := identifierColumn(content, toNode.StartLine, toNode.Name)
|
|
refs, err := p.findReferences(absRoot, grp.rel, line, col)
|
|
cr = cachedRefs{refs: refs, ok: err == nil}
|
|
refsByTarget[t.edge.To] = cr
|
|
}
|
|
if !cr.ok {
|
|
continue
|
|
}
|
|
if p.confirmRefMatchesSite(cr.refs, absRoot, repoPrefix, t) {
|
|
rmu.Lock()
|
|
semantic.ConfirmEdge(t.edge, p.Name())
|
|
semantic.PersistEdge(g, t.edge)
|
|
rmu.Unlock()
|
|
confirmMu.Lock()
|
|
result.EdgesConfirmed++
|
|
confirmMu.Unlock()
|
|
continue
|
|
}
|
|
// Unconfirmed with a recorded site line: defer to the
|
|
// serial definition-rebind fallback below.
|
|
if t.edge.Line > 0 {
|
|
confirmMu.Lock()
|
|
fallback = append(fallback, t)
|
|
confirmMu.Unlock()
|
|
}
|
|
}
|
|
}(grp)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// Serial definition-rebind fallback: for a site the reference sweep did
|
|
// not tie to the edge's target, ask the server what the site actually
|
|
// resolves to (textDocument/definition at the site identifier). When the
|
|
// definition lands back on the target we confirm anyway (reference lists
|
|
// can be incomplete); when it names a DIFFERENT known declaration we
|
|
// rebind the edge to the correct target instead of leaving a
|
|
// misattribution behind. Runs after the parallel sweep so arbitrary
|
|
// call-site document opens never overlap across goroutines.
|
|
//
|
|
// Sites are ordered by their call-site file so one session acquire
|
|
// serves every site in a file — a caller with many misbound sites in
|
|
// one file is opened once, not once per site. Stable so sites keep
|
|
// their relative order within a file.
|
|
sort.SliceStable(fallback, func(i, j int) bool {
|
|
return edgeSiteRelPath(fallback[i].edge, repoPrefix, nodeRelPath(fallback[i].node)) <
|
|
edgeSiteRelPath(fallback[j].edge, repoPrefix, nodeRelPath(fallback[j].node))
|
|
})
|
|
defSiteCache := map[string]*graph.Node{}
|
|
var (
|
|
curSiteRel string
|
|
curContent []byte
|
|
relSite func()
|
|
siteOpen bool
|
|
)
|
|
releaseSite := func() {
|
|
if siteOpen {
|
|
relSite()
|
|
siteOpen = false
|
|
}
|
|
curContent = nil
|
|
}
|
|
for _, t := range fallback {
|
|
if targetedCtx.Err() != nil {
|
|
break
|
|
}
|
|
toNode := g.GetNode(t.edge.To)
|
|
if toNode == nil {
|
|
continue
|
|
}
|
|
callerRel := nodeRelPath(t.node)
|
|
siteRel := edgeSiteRelPath(t.edge, repoPrefix, callerRel)
|
|
if !p.servesFile(siteRel) {
|
|
continue // never open a call-site file this server can't compile
|
|
}
|
|
if degradedSkipFile != nil && degradedSkipFile(siteRel) {
|
|
continue // degraded mode: never open this call-site (e.g. a header)
|
|
}
|
|
// Hold one open document per run of same-file sites: acquire on the
|
|
// first site of a file, release when the file changes (and once more
|
|
// after the loop). A failed acquire yields nil content, which
|
|
// definitionNodeAtSite treats as a no-verdict — the same outcome the
|
|
// per-site openDocument failure produced before.
|
|
if siteRel != curSiteRel {
|
|
releaseSite()
|
|
curSiteRel = siteRel
|
|
content, release, err := session.acquire(p.client, filepath.Join(absRoot, siteRel))
|
|
if err == nil {
|
|
curContent = content
|
|
relSite = release
|
|
siteOpen = true
|
|
}
|
|
}
|
|
siteLine := t.edge.Line
|
|
cand, ok := p.definitionNodeAtSite(g, repoPrefix, absRoot, siteRel, siteLine, toNode.Name, curContent, defSiteCache)
|
|
switch {
|
|
case !ok || cand == nil:
|
|
// No verdict — leave the edge at its heuristic tier so
|
|
// min_tier filtering excludes it.
|
|
case cand.ID == toNode.ID:
|
|
rmu.Lock()
|
|
semantic.ConfirmEdge(t.edge, p.Name())
|
|
semantic.PersistEdge(g, t.edge)
|
|
rmu.Unlock()
|
|
result.EdgesConfirmed++
|
|
case rebindTargetAcceptable(cand.Kind) && !edgeExistsAt(g, t.edge.From, cand.ID, t.edge.Kind, t.edge.Line):
|
|
rmu.Lock()
|
|
// Mutate the full edge state BEFORE ReindexEdge: disk
|
|
// backends persist the post-mutation struct verbatim
|
|
// (delete old key + insert current state), so anything
|
|
// stamped afterwards would be dropped.
|
|
oldTo := t.edge.To
|
|
t.edge.To = cand.ID
|
|
semantic.ConfirmEdge(t.edge, p.Name())
|
|
t.edge.Meta["rebound_from"] = oldTo
|
|
g.ReindexEdge(t.edge, oldTo)
|
|
rmu.Unlock()
|
|
result.EdgesConfirmed++
|
|
}
|
|
}
|
|
releaseSite()
|
|
|
|
// Degraded finalisation: the interface pass, the references-add pass, and
|
|
// the per-file hover / hierarchy sweep are all skipped when a needed
|
|
// compilation database is missing — only the reference-confirm and
|
|
// definition-rebind passes ran, working inside the fallback translation
|
|
// unit. Finalise the result here, before the sweep machinery below.
|
|
if degraded {
|
|
if result.SymbolsTotal > 0 {
|
|
result.CoveragePercent = float64(result.SymbolsCovered) / float64(result.SymbolsTotal) * 100
|
|
}
|
|
result.DurationMs = time.Since(start).Milliseconds()
|
|
if ctx.Err() != nil {
|
|
result.Partial = true
|
|
result.AbortReason = ctx.Err().Error()
|
|
}
|
|
if p.logger != nil {
|
|
didOpens, reopenedFiles, docEvictions, peakOpenDocs := session.stats()
|
|
p.logger.Info("LSP enrich: degraded pass complete (reference confirmation only)",
|
|
zap.String("provider", p.Name()),
|
|
zap.String("repo_prefix", repoPrefix),
|
|
zap.Bool("degraded", true),
|
|
zap.Int("edges_confirmed", result.EdgesConfirmed),
|
|
zap.Int("did_opens", didOpens),
|
|
zap.Int("reopened_files", reopenedFiles),
|
|
zap.Int("doc_evictions", docEvictions),
|
|
zap.Int("peak_open_docs", peakOpenDocs),
|
|
zap.Int64("req_references", p.reqStats.references.Load()),
|
|
zap.Int64("req_definitions", p.reqStats.definitions.Load()),
|
|
)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// References-driven add pass: a server that enumerates references but has
|
|
// no call hierarchy (e.g. intelephense) never runs the per-file sweep's
|
|
// hierarchy hops, so the dispatch call sites it can see are confirmed but
|
|
// never ADDED. Ask textDocument/references per declaration and mint those
|
|
// call edges. Runs under the targeted budget (before the hover sweep) so
|
|
// a deadline cut sheds hover work, not the recall-bearing add.
|
|
if p.Supports("textDocument/references") && !p.Supports("textDocument/prepareCallHierarchy") {
|
|
p.referencesAddPass(targetedCtx, g, repoPrefix, absRoot, langNodes, rmu, session, result)
|
|
}
|
|
|
|
// Per-file document lifecycle + bounded concurrency. The original
|
|
// implementation bulk-opened every target file up front and closed
|
|
// them all in one deferred sweep after a fully sequential hover loop —
|
|
// at peak that pinned tens of thousands of documents open in the
|
|
// language server and OOM-killed it. The fix bounds the open set, but
|
|
// must keep a file open for the whole span of its symbols' hovers: a
|
|
// per-node open/close re-opened the file once per symbol whenever a
|
|
// file's per-node goroutines did not overlap in time (common on a
|
|
// loaded CI runner), so didOpen was no longer sent exactly once per
|
|
// file (TestLSP_Provider_OpensEachFileOnce). Enrichment is therefore
|
|
// grouped by file — one goroutine per file opens it exactly once, fans
|
|
// its symbols out across a shared hover budget, then closes it exactly
|
|
// once. fileSem caps the simultaneously-open documents at maxParallel
|
|
// (the original OOM trigger); hoverSem caps concurrent hovers at
|
|
// maxParallel independently, so a single many-symbol file still hovers
|
|
// in parallel.
|
|
enrichedNodes := make(map[string]bool)
|
|
|
|
// Race-safe metric counters for the concurrent hover phase.
|
|
var diagTotalNodes, diagHoverOK, diagHoverErr, diagHoverNil, diagTypeEmpty, diagEnriched, diagNoPosition, diagHoverSkipped atomic.Int64
|
|
|
|
// mu guards the cross-goroutine aggregation: per-file stamp buffers,
|
|
// enrichedNodes, the EnrichResult node counters, and the best-effort
|
|
// first-sample diagnostics below.
|
|
var mu sync.Mutex
|
|
var diagFirstHoverValue, diagFirstHoverError, diagFirstNodeName, diagFirstNodeFile string
|
|
|
|
// activeClient is the client the hover goroutines currently target.
|
|
// reconnectWithBackoff swaps it (under reconnectMu) when the server
|
|
// dies mid-flight; goroutines load it atomically so the swap never
|
|
// races an in-flight hover.
|
|
var activeClient atomic.Pointer[Client]
|
|
activeClient.Store(p.client)
|
|
|
|
// Abort coordination: if reconnection fails permanently, the first
|
|
// goroutine to learn it records the error and flips aborted; the rest
|
|
// stop early and Enrich returns that error.
|
|
var aborted atomic.Bool
|
|
var abortErr error
|
|
var abortOnce sync.Once
|
|
// reconnectCycles counts distinct reconnect cycles this pass — a server
|
|
// that connects fine but keeps exiting mid-request (e.g. clangd crashing in
|
|
// a lint matcher) would otherwise reconnect endlessly, repeating work and
|
|
// pinning the process at high CPU. The crash-loop guard caps it.
|
|
var reconnectCycles atomic.Int64
|
|
|
|
// reconnect serialises mid-flight recovery so that when a burst of
|
|
// goroutines observe the same dead client only the first rebuilds it;
|
|
// the others wait on reconnectMu and then reuse the fresh client.
|
|
reconnect := func(stale *Client) (*Client, error) {
|
|
p.reconnectMu.Lock()
|
|
defer p.reconnectMu.Unlock()
|
|
if aborted.Load() {
|
|
return nil, abortErr
|
|
}
|
|
if cur := activeClient.Load(); cur != stale {
|
|
return cur, nil // someone else already reconnected
|
|
}
|
|
// Crash-loop guard: past the per-pass cap, abandon this provider's
|
|
// enrichment for the repo rather than reconnect again. Whatever already
|
|
// flushed stays in the graph; the caller sees the failure and the log
|
|
// names the provider, repo, and reconnect count.
|
|
if cycles := reconnectCycles.Add(1); cycles > maxEnrichReconnectCycles {
|
|
err := fmt.Errorf("LSP provider %q exited %d times during enrichment of repo %q; abandoning pass",
|
|
p.Name(), cycles, repoPrefix)
|
|
abortOnce.Do(func() {
|
|
abortErr = err
|
|
aborted.Store(true)
|
|
})
|
|
p.logger.Warn("LSP enrich: crash-loop guard tripped, abandoning provider",
|
|
zap.String("provider", p.Name()),
|
|
zap.String("repo_prefix", repoPrefix),
|
|
zap.Int64("reconnect_cycles", cycles),
|
|
zap.Int64("reconnect_attempts", p.reconnectAttempts.Load()),
|
|
)
|
|
return nil, err
|
|
}
|
|
newC, err := p.reconnectWithBackoff(absRoot)
|
|
if err != nil {
|
|
abortOnce.Do(func() {
|
|
abortErr = err
|
|
aborted.Store(true)
|
|
})
|
|
return nil, err
|
|
}
|
|
activeClient.Store(newC)
|
|
return newC, nil
|
|
}
|
|
|
|
// Group enrichment targets by file so each file's open/close lifecycle
|
|
// spans all of its symbols. Files keep encounter order; symbols keep
|
|
// their order within a file.
|
|
type fileTargets struct {
|
|
rel string
|
|
nodes []*graph.Node
|
|
demand int // declarations still carrying unresolved same-name candidates
|
|
dispatch bool // carries a type / interface whose hierarchy the sweep interrogates
|
|
}
|
|
var fileList []*fileTargets
|
|
fileIndex := map[string]*fileTargets{}
|
|
for _, n := range langNodes {
|
|
rel := nodeRelPath(n)
|
|
if !p.servesFile(rel) {
|
|
continue // never open a file this server can't compile
|
|
}
|
|
ft := fileIndex[n.FilePath]
|
|
if ft == nil {
|
|
ft = &fileTargets{rel: rel}
|
|
fileIndex[n.FilePath] = ft
|
|
fileList = append(fileList, ft)
|
|
}
|
|
ft.nodes = append(ft.nodes, n)
|
|
if enrichNodeHasUnresolvedDemand(g, n) {
|
|
ft.demand++
|
|
}
|
|
if enrichNodeIsDispatchRelevant(n) {
|
|
ft.dispatch = true
|
|
}
|
|
}
|
|
// Demand-driven ordering: enrich the files whose declarations still carry
|
|
// unresolved same-name call candidates first. Under a per-repo deadline the
|
|
// budget is then spent where the LSP references pass will actually bind
|
|
// dropped call sites, instead of on declarations static resolution already
|
|
// covered. Stable so files of equal demand keep encounter order.
|
|
sort.SliceStable(fileList, func(i, j int) bool {
|
|
return fileList[i].demand > fileList[j].demand
|
|
})
|
|
|
|
// Sweep gate: the per-file hover / call-hierarchy sweep below is the
|
|
// whole-repo churn a warm restart pays to confirm zero new edges. The
|
|
// resolved mode (GORTEX_LSP_SWEEP env override > router-configured field
|
|
// > demand-gated default) decides which files it visits — sweepFile
|
|
// skips a file the mode excludes. "demand" (default) sweeps a file that
|
|
// still carries unresolved same-name candidates OR declares a type /
|
|
// interface whose super/subtype hierarchy only this sweep recovers,
|
|
// "full" sweeps every file, "off" skips the sweep entirely. The
|
|
// tier-deciding confirm / add / interface passes above are never gated.
|
|
sweepMode := p.effectiveSweepMode()
|
|
|
|
// Call- and type-hierarchy hops are collected per file (while the
|
|
// file is open) and applied in that file's flush below, so each
|
|
// file's graph mutations land as soon as the file completes while
|
|
// the file is still opened exactly once per pass.
|
|
type callHop struct {
|
|
n *graph.Node
|
|
other CallHierarchyItem
|
|
asOutgoing bool
|
|
// fromRanges carries the call-expression ranges reported by
|
|
// the server — per the LSP spec these are always ranges
|
|
// inside the CALLER's file, for both incoming and outgoing
|
|
// hops. Kept so the landed edge can be stamped at the real
|
|
// call-site line instead of the caller's declaration line.
|
|
fromRanges []Range
|
|
}
|
|
type typeHop struct {
|
|
n *graph.Node
|
|
other TypeHierarchyItem
|
|
asSupertype bool
|
|
}
|
|
|
|
// Only interrogate the server for call / type hierarchy when it
|
|
// advertised the capability. Skipping otherwise avoids the
|
|
// "non-added document" / method-not-found churn against servers (or
|
|
// languages) that do not implement it.
|
|
callHierOK := p.Supports("textDocument/prepareCallHierarchy")
|
|
typeHierOK := p.Supports("textDocument/prepareTypeHierarchy")
|
|
|
|
// flushFile lands one completed file's work into the graph: hover
|
|
// stamps plus call/type-hierarchy hops. EnrichNodeMeta mutates
|
|
// Node.Meta in place, but on disk backends the node is a per-call
|
|
// reconstruction, so stamped nodes must be round-tripped through the
|
|
// store (AddBatch) or the semantic_type stamp is discarded — see
|
|
// semantic.EnrichNodeMeta. Committing per file — instead of buffering
|
|
// the whole repo and applying after the sweep — is what makes a
|
|
// deadline cut lose only unfinished files, never completed work.
|
|
// Graph mutations and the result edge counters inside
|
|
// recordHierarchyCall / linkTypeHierarchy serialise on the resolve
|
|
// mutex; the counters are next read after wg.Wait, so they stay
|
|
// race-free.
|
|
flushFile := func(stamped []*graph.Node, cHops []callHop, tHops []typeHop) {
|
|
if len(stamped) == 0 && len(cHops) == 0 && len(tHops) == 0 {
|
|
return
|
|
}
|
|
rmu.Lock()
|
|
defer rmu.Unlock()
|
|
if len(stamped) > 0 {
|
|
g.AddBatch(stamped, nil)
|
|
}
|
|
for _, h := range cHops {
|
|
p.recordHierarchyCall(g, repoPrefix, absRoot, h.n, h.other, h.asOutgoing, h.fromRanges, result)
|
|
}
|
|
for _, h := range tHops {
|
|
p.linkTypeHierarchy(g, repoPrefix, absRoot, h.n, h.other, h.asSupertype, result)
|
|
}
|
|
}
|
|
|
|
// fileSem bounds the number of simultaneously-open documents; hoverSem
|
|
// bounds concurrent hovers across all open files. Both at maxParallel:
|
|
// holding a file open never consumes a hover slot, so one file with
|
|
// many symbols still hovers maxParallel-wide, while many single-symbol
|
|
// files keep at most maxParallel documents open at once.
|
|
fileSem := make(chan struct{}, p.maxParallel)
|
|
hoverSem := make(chan struct{}, p.maxParallel)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, ft := range fileList {
|
|
if aborted.Load() || ctx.Err() != nil {
|
|
break
|
|
}
|
|
if !sweepFile(sweepMode, ft.demand, ft.dispatch) {
|
|
continue // mode excludes this file from the per-file sweep
|
|
}
|
|
wg.Add(1)
|
|
fileSem <- struct{}{} // acquire — bounds simultaneously-open docs
|
|
go func(ft *fileTargets) {
|
|
defer func() {
|
|
<-fileSem
|
|
wg.Done()
|
|
}()
|
|
if aborted.Load() || ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
absPath := filepath.Join(absRoot, ft.rel)
|
|
|
|
// Pin the file open on the active client through the shared
|
|
// session for the whole span of this file's hierarchy + hover
|
|
// work — exactly one didOpen per file on the happy path, and the
|
|
// content is read from disk once. The session dedupes per
|
|
// (client, path), so a hover goroutine that reconnects re-opens
|
|
// the file on the fresh client (once) without a second open on
|
|
// the client this pin holds; closeAll pairs every open with a
|
|
// didClose at pass end.
|
|
content, release, err := session.acquire(activeClient.Load(), absPath)
|
|
if err != nil {
|
|
p.logger.Debug("LSP enrich: didOpen failed",
|
|
zap.String("file", ft.rel), zap.Error(err))
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
// Per-file result buffers, flushed into the graph when this
|
|
// file finishes (or is cut mid-file by cancellation) —
|
|
// deferred so even a partially-processed file keeps what it
|
|
// completed.
|
|
var fileStamped []*graph.Node // guarded by mu during the hover fan-out
|
|
var cHops []callHop
|
|
var tHops []typeHop
|
|
defer func() { flushFile(fileStamped, cHops, tHops) }()
|
|
|
|
// While the file is open on the server, interrogate it for
|
|
// call- and type-hierarchy edges the AST extractor may have
|
|
// missed. This runs BEFORE the hover fan-out: hierarchy hops
|
|
// become lsp_resolved edges — far more accuracy-bearing than
|
|
// hover type strings — so a deadline cut mid-file sheds hover
|
|
// work, not edges. Running while the document is added also
|
|
// keeps prepare* from failing with "non-added document" and
|
|
// preserves exactly one didOpen per file.
|
|
if callHierOK || typeHierOK {
|
|
for _, n := range ft.nodes {
|
|
if aborted.Load() || ctx.Err() != nil {
|
|
break
|
|
}
|
|
line, ok := lspLine(n)
|
|
if !ok {
|
|
continue
|
|
}
|
|
col := identifierColumn(content, n.StartLine, n.Name)
|
|
switch n.Kind {
|
|
case graph.KindFunction, graph.KindMethod:
|
|
if !callHierOK {
|
|
continue
|
|
}
|
|
items, err := p.prepareCallHierarchy(absRoot, ft.rel, line, col)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Outgoing always: the file sweep visits every caller,
|
|
// so a declaration's outgoing hops alone reconstruct
|
|
// every intra-repo static call edge. Incoming only adds
|
|
// what the outgoing side is blind to — the concrete
|
|
// callers of a dynamic-dispatch target, and names the
|
|
// resolver still could not bind — so it is fetched only
|
|
// for a dispatch-relevant or demand-bearing declaration
|
|
// (or under a full sweep). Demand-first file ordering
|
|
// sweeps the demand-bearing callers before any deadline
|
|
// cut, so a skipped incoming costs no reachable edge.
|
|
wantIncoming := sweepMode == sweepModeFull ||
|
|
enrichCallableIsDispatchRelevant(g, n) ||
|
|
enrichNodeHasUnresolvedDemand(g, n)
|
|
for _, item := range items {
|
|
if outs, oerr := p.outgoingCalls(item); oerr == nil {
|
|
for _, oc := range outs {
|
|
cHops = append(cHops, callHop{n: n, other: oc.To, asOutgoing: true, fromRanges: oc.FromRanges})
|
|
}
|
|
}
|
|
if !wantIncoming {
|
|
p.reqStats.incomingSkipped.Add(1)
|
|
continue
|
|
}
|
|
if ins, ierr := p.incomingCalls(item); ierr == nil {
|
|
for _, ic := range ins {
|
|
cHops = append(cHops, callHop{n: n, other: ic.From, asOutgoing: false, fromRanges: ic.FromRanges})
|
|
}
|
|
}
|
|
}
|
|
case graph.KindType, graph.KindInterface:
|
|
if !typeHierOK {
|
|
continue
|
|
}
|
|
items, err := p.prepareTypeHierarchy(absRoot, ft.rel, line, col)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, item := range items {
|
|
if sups, serr := p.supertypes(item); serr == nil {
|
|
for _, s := range sups {
|
|
tHops = append(tHops, typeHop{n: n, other: s, asSupertype: true})
|
|
}
|
|
}
|
|
if subs, serr := p.subtypes(item); serr == nil {
|
|
for _, s := range subs {
|
|
tHops = append(tHops, typeHop{n: n, other: s, asSupertype: false})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var nodeWg sync.WaitGroup
|
|
for _, n := range ft.nodes {
|
|
if aborted.Load() || ctx.Err() != nil {
|
|
break
|
|
}
|
|
// Hover-skip: a node already carrying a semantic_type stamp
|
|
// (e.g. reloaded on a warm restart) gains nothing from another
|
|
// hover — the derived type string is unchanged. Skip it so a
|
|
// file swept for its call / type-hierarchy edges does not
|
|
// re-pay the whole-file hover cost; the hierarchy interrogation
|
|
// above already ran for every node regardless.
|
|
if nodeHasSemanticType(n) {
|
|
diagHoverSkipped.Add(1)
|
|
continue
|
|
}
|
|
line, ok := lspLine(n)
|
|
if !ok {
|
|
// No real source position — the request would carry
|
|
// line -1 and be rejected by the server. Skip.
|
|
diagNoPosition.Add(1)
|
|
continue
|
|
}
|
|
diagTotalNodes.Add(1)
|
|
nodeWg.Add(1)
|
|
hoverSem <- struct{}{} // acquire — bounds concurrent hovers
|
|
go func(n *graph.Node, line int) {
|
|
defer func() {
|
|
<-hoverSem
|
|
nodeWg.Done()
|
|
}()
|
|
if aborted.Load() || ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
c := activeClient.Load()
|
|
// Ensure the doc is open on this goroutine's client. On
|
|
// the happy path this dedupes against the file goroutine's
|
|
// pin (no didOpen); when the active client was swapped by a
|
|
// concurrent reconnect it opens the file on the new client.
|
|
_, releaseHover, err := session.acquire(c, absPath)
|
|
if err != nil {
|
|
p.logger.Debug("LSP enrich: didOpen failed",
|
|
zap.String("file", n.FilePath), zap.Error(err))
|
|
return
|
|
}
|
|
defer releaseHover()
|
|
|
|
col := identifierColumn(content, n.StartLine, n.Name)
|
|
hoverResult, err := p.hoverWith(c, absRoot, nodeRelPath(n), line, col)
|
|
if err != nil && isServerExitError(err) {
|
|
// Server died mid-flight — recover once and retry this
|
|
// node's hover against the fresh session. The new client
|
|
// has no record of our document, so re-open it there
|
|
// (the session dedupes the re-open across this file's
|
|
// goroutines) before retrying.
|
|
newC, rerr := reconnect(c)
|
|
if rerr != nil {
|
|
return // aborted; wg.Wait + abort check below handles it
|
|
}
|
|
c = newC
|
|
var releaseNew func()
|
|
_, releaseNew, err = session.acquire(c, absPath)
|
|
if err != nil {
|
|
p.logger.Debug("LSP enrich: reopen after reconnect failed",
|
|
zap.String("file", n.FilePath), zap.Error(err))
|
|
return
|
|
}
|
|
defer releaseNew()
|
|
hoverResult, err = p.hoverWith(c, absRoot, nodeRelPath(n), line, col)
|
|
}
|
|
if err != nil {
|
|
diagHoverErr.Add(1)
|
|
mu.Lock()
|
|
if diagFirstHoverError == "" {
|
|
diagFirstHoverError = err.Error()
|
|
diagFirstNodeName = n.Name
|
|
diagFirstNodeFile = n.FilePath
|
|
}
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
if hoverResult == nil {
|
|
diagHoverNil.Add(1)
|
|
return
|
|
}
|
|
diagHoverOK.Add(1)
|
|
|
|
typeInfo := extractTypeFromHover(hoverResult.Contents.Value)
|
|
mu.Lock()
|
|
if diagFirstHoverValue == "" {
|
|
diagFirstHoverValue = hoverResult.Contents.Value
|
|
if len(diagFirstHoverValue) > 200 {
|
|
diagFirstHoverValue = diagFirstHoverValue[:200]
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
if typeInfo == "" {
|
|
diagTypeEmpty.Add(1)
|
|
return
|
|
}
|
|
|
|
semantic.EnrichNodeMeta(n, "semantic_type", typeInfo, p.Name())
|
|
diagEnriched.Add(1)
|
|
mu.Lock()
|
|
fileStamped = append(fileStamped, n)
|
|
if !enrichedNodes[n.ID] {
|
|
result.NodesEnriched++
|
|
result.SymbolsCovered++
|
|
enrichedNodes[n.ID] = true
|
|
}
|
|
mu.Unlock()
|
|
}(n, line)
|
|
}
|
|
nodeWg.Wait()
|
|
}(ft)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Enrichment metrics (acceptance criterion 6): hover outcomes, the
|
|
// shared document session's open/reopen/eviction accounting, and the
|
|
// per-method request volume that drove the pass.
|
|
didOpens, reopenedFiles, docEvictions, peakOpenDocs := session.stats()
|
|
p.logger.Info("LSP enrich: hover phase complete",
|
|
zap.String("sweep_mode", sweepMode),
|
|
zap.Int64("total_nodes", diagTotalNodes.Load()),
|
|
zap.Int64("hover_ok", diagHoverOK.Load()),
|
|
zap.Int64("hover_err", diagHoverErr.Load()),
|
|
zap.Int64("hover_nil", diagHoverNil.Load()),
|
|
zap.Int64("type_empty", diagTypeEmpty.Load()),
|
|
zap.Int64("enriched", diagEnriched.Load()),
|
|
zap.Int("skipped_already_stamped", skippedAlreadyStamped),
|
|
zap.Int("hover_candidates", result.HoverCandidates),
|
|
zap.Int64("skipped_no_position", diagNoPosition.Load()),
|
|
zap.Int64("hover_sweep_skipped", diagHoverSkipped.Load()),
|
|
zap.Int64("reconnect_attempts", p.reconnectAttempts.Load()),
|
|
zap.Int("did_opens", didOpens),
|
|
zap.Int("reopened_files", reopenedFiles),
|
|
zap.Int("doc_evictions", docEvictions),
|
|
zap.Int("peak_open_docs", peakOpenDocs),
|
|
zap.Int64("req_references", p.reqStats.references.Load()),
|
|
zap.Int64("req_implementations", p.reqStats.implementations.Load()),
|
|
zap.Int64("req_definitions", p.reqStats.definitions.Load()),
|
|
zap.Int64("req_hovers", p.reqStats.hovers.Load()),
|
|
zap.Int64("req_prepare_call_hierarchy", p.reqStats.prepareCallHierarchy.Load()),
|
|
zap.Int64("req_outgoing_calls", p.reqStats.outgoingCalls.Load()),
|
|
zap.Int64("req_incoming_calls", p.reqStats.incomingCalls.Load()),
|
|
zap.Int64("incoming_calls_skipped", p.reqStats.incomingSkipped.Load()),
|
|
zap.Int64("req_prepare_type_hierarchy", p.reqStats.prepareTypeHierarchy.Load()),
|
|
zap.Int64("req_supertypes", p.reqStats.supertypes.Load()),
|
|
zap.Int64("req_subtypes", p.reqStats.subtypes.Load()),
|
|
zap.String("first_hover_value", diagFirstHoverValue),
|
|
zap.String("first_hover_error", diagFirstHoverError),
|
|
zap.String("first_node_name", diagFirstNodeName),
|
|
zap.String("first_node_file", diagFirstNodeFile),
|
|
)
|
|
|
|
if result.SymbolsTotal > 0 {
|
|
result.CoveragePercent = float64(result.SymbolsCovered) / float64(result.SymbolsTotal) * 100
|
|
}
|
|
result.DurationMs = time.Since(start).Milliseconds()
|
|
|
|
if aborted.Load() {
|
|
// Whatever was flushed before the abort stays in the graph; the
|
|
// result rides along so the caller can record its counts.
|
|
return result, fmt.Errorf("LSP enrichment aborted: %w", abortErr)
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
// Deadline / cancellation: everything flushed so far is in the
|
|
// graph and counted; only the un-visited remainder was skipped.
|
|
result.Partial = true
|
|
result.AbortReason = ctx.Err().Error()
|
|
p.logger.Warn("LSP enrich: pass cancelled at deadline; completed work already landed",
|
|
zap.String("repo_prefix", repoPrefix),
|
|
zap.Int("edges_confirmed", result.EdgesConfirmed),
|
|
zap.Int("edges_added", result.EdgesAdded),
|
|
zap.Int("nodes_enriched", result.NodesEnriched),
|
|
zap.Error(ctx.Err()),
|
|
)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) EnrichFile(g graph.Store, repoRoot, filePath string) (*semantic.EnrichResult, error) {
|
|
// LSP supports incremental updates, but for simplicity we skip it.
|
|
// The full Enrich pass handles this.
|
|
return nil, nil
|
|
}
|
|
|
|
// resetForReconnect clears the per-connection state that a dead client
|
|
// invalidated: open-document tracking, doc versions, and the dynamic
|
|
// capability table. The server (whether a freshly spawned subprocess
|
|
// or a freshly dialed IDE) has no knowledge of the documents we
|
|
// previously opened against the dead session — re-opening on first
|
|
// touch lets the next call (textDocument/hover etc.) succeed.
|
|
//
|
|
// Caps are reset separately at the top of ensureClient so the reset
|
|
// also covers the initial-connect path; here we only clear what the
|
|
// reconnect-specific recovery needs.
|
|
func (p *Provider) resetForReconnect() {
|
|
// Drop the dead client so the next ensureClient branch builds a
|
|
// fresh transport. Close it best-effort first to free any
|
|
// pending pending-map entries; the dead read loop already closed
|
|
// `done` so Shutdown() is a no-op past that point.
|
|
if p.client != nil {
|
|
_ = p.client.Shutdown()
|
|
p.client = nil
|
|
}
|
|
p.docMu.Lock()
|
|
p.docVersions = map[string]int{}
|
|
p.openDocs = map[string]bool{}
|
|
p.lastDiag = map[string][]Diagnostic{}
|
|
p.docMu.Unlock()
|
|
// sourceCache is the unsynchronised interactive-navigation cache
|
|
// (written lock-free by openDocument); drop it on reconnect so a
|
|
// long-lived provider doesn't carry the dead session's file bytes.
|
|
// nil is the freed state — openDocument lazily re-creates it and
|
|
// getSource nil-checks before reading.
|
|
p.sourceCache = nil
|
|
}
|
|
|
|
// dialOrSpawn builds the LSP client according to the provider's spec.
|
|
// When p.connect is set, it dials the configured endpoint; on dial
|
|
// failure with FallbackSpawn=true it falls back to spawning the
|
|
// subprocess; with FallbackSpawn=false it returns the dial error so
|
|
// the language stays unavailable rather than racing the IDE.
|
|
//
|
|
// The dial path retries with exponential backoff capped at
|
|
// maxDialBackoff. Each successful dial resets the backoff window.
|
|
func (p *Provider) dialOrSpawn(workspaceRoot string) (*Client, error) {
|
|
if p.connect != nil {
|
|
if err := p.connect.Validate(); err != nil {
|
|
return nil, fmt.Errorf("lsp passive attach: %w", err)
|
|
}
|
|
client, dialErr := NewClientWithTransport(&DialTransport{
|
|
Network: p.connect.Network,
|
|
Address: p.connect.Address,
|
|
}, p.logger)
|
|
if dialErr == nil {
|
|
// Reset the backoff so a flap doesn't punish the next
|
|
// reconnect with the last failure's window.
|
|
p.dialBackoff = dialBackoffStart
|
|
return client, nil
|
|
}
|
|
// Bump the backoff for the next attempt — callers that retry
|
|
// immediately on failure can pace themselves via
|
|
// SinceLastDialAttempt(), and the router's reaper / on-demand
|
|
// callers naturally space attempts apart.
|
|
nextBackoff := p.dialBackoff * 2
|
|
if nextBackoff > maxDialBackoff {
|
|
nextBackoff = maxDialBackoff
|
|
}
|
|
if nextBackoff <= 0 {
|
|
nextBackoff = dialBackoffStart
|
|
}
|
|
p.dialBackoff = nextBackoff
|
|
|
|
if !p.connect.FallbackSpawn {
|
|
return nil, fmt.Errorf("lsp passive attach %s %s: %w (no spawn fallback configured)",
|
|
p.connect.Network, p.connect.Address, dialErr)
|
|
}
|
|
// Fallback to spawn — log loudly so operators see the IDE
|
|
// went away. The next ensureClient will retry dial first
|
|
// (resetForReconnect clears p.client), so once the IDE
|
|
// comes back we drift back to the passive path on next
|
|
// reconnect.
|
|
if p.logger != nil {
|
|
p.logger.Warn("lsp: passive dial failed, falling back to spawn",
|
|
zap.String("network", p.connect.Network),
|
|
zap.String("address", p.connect.Address),
|
|
zap.Error(dialErr),
|
|
)
|
|
}
|
|
}
|
|
if p.command == "" {
|
|
return nil, fmt.Errorf("lsp: no command configured and no passive attach available")
|
|
}
|
|
args := p.args
|
|
// jdtls with no -data lets the launcher default its Eclipse workspace to
|
|
// ~/Library/Caches/jdtls/<hash>, outside Gortex's cache isolation. Pin it
|
|
// under the resolved cache home per repo instead.
|
|
if isJdtlsCommand(p.command) {
|
|
args = jdtlsDataArgs(args, workspaceRoot)
|
|
}
|
|
return NewClient(p.command, args, p.env, workspaceRoot, p.logger)
|
|
}
|
|
|
|
// defaultLSPCallTimeout bounds a single post-initialize LSP request.
|
|
// A wedged server — e.g. csharp-ls stuck loading an MSBuild workspace,
|
|
// alive but never replying — would otherwise let an enrichment hover /
|
|
// findReferences Call block forever and stall the whole enrichment
|
|
// WaitGroup. The initialize handshake itself is left unbounded (a cold
|
|
// .NET / Java workspace load can legitimately run for minutes).
|
|
const defaultLSPCallTimeout = 30 * time.Second
|
|
|
|
// lspCallTimeout resolves the post-initialize Call bound, honouring the
|
|
// GORTEX_LSP_CALL_TIMEOUT env override (a Go duration such as "45s";
|
|
// "0" / "off" / "none" disables the bound). An unparseable value falls
|
|
// back to the default.
|
|
func lspCallTimeout() time.Duration {
|
|
switch v := strings.TrimSpace(os.Getenv("GORTEX_LSP_CALL_TIMEOUT")); v {
|
|
case "":
|
|
return defaultLSPCallTimeout
|
|
case "0", "off", "none":
|
|
return 0
|
|
default:
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
return d
|
|
}
|
|
return defaultLSPCallTimeout
|
|
}
|
|
}
|
|
|
|
// servesCSharp reports whether this provider routes C# (.cs) files. Used to
|
|
// scope the C#-specific pre-restore and diagnostic-filter behaviour so it can
|
|
// never touch another language's provider.
|
|
func (p *Provider) servesCSharp() bool {
|
|
for _, l := range p.languages {
|
|
if l == "csharp" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CSharpDiagFilterEnv toggles the C# advisory-diagnostic filter (see
|
|
// filterCSharpAdvisoryDiags). ON by default; set to a falsey value
|
|
// ("0" / "off" / "false" / "none") to pass every diagnostic through unchanged.
|
|
const CSharpDiagFilterEnv = "GORTEX_LSP_CSHARP_DIAG_FILTER"
|
|
|
|
// csharpDiagFilterEnabled reports whether the C# advisory-diagnostic filter is
|
|
// active. Default ON — the filter only drops NuGet advisory codes, which are
|
|
// never code-level problems an indexer acts on.
|
|
func csharpDiagFilterEnabled() bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(CSharpDiagFilterEnv))) {
|
|
case "0", "off", "false", "none":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// CSharpRestoreEnv toggles the C# pre-spawn `dotnet restore` (see
|
|
// Provider.maybeCSharpPreRestore). ON by default; set to a falsey value
|
|
// ("0" / "off" / "false" / "none") to skip it — e.g. offline / air-gapped
|
|
// indexing, or to keep indexing off the network.
|
|
const CSharpRestoreEnv = "GORTEX_LSP_CSHARP_RESTORE"
|
|
|
|
// csharpRestoreEnabled reports whether the C# pre-spawn restore is active.
|
|
// Default ON: gortex only restores repositories the user has explicitly added
|
|
// (it never auto-discovers), and spawning the C# server already evaluates the
|
|
// project's MSBuild graph — so restore adds no execution surface beyond the
|
|
// workspace load it precedes, while letting the Roslyn workspace load every
|
|
// project instead of dropping audit-flagged ones and reporting false errors.
|
|
func csharpRestoreEnabled() bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(CSharpRestoreEnv))) {
|
|
case "0", "off", "false", "none":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// diagCodeString renders a Diagnostic.Code as a string when it is a string
|
|
// code (NuGet / Roslyn codes such as "NU1902" / "CS0246"); numeric codes
|
|
// return "". Sufficient for the NuGet-advisory check, which only matches NU####.
|
|
func diagCodeString(code any) string {
|
|
switch c := code.(type) {
|
|
case string:
|
|
return c
|
|
case json.Number:
|
|
return c.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// isNuGetAdvisoryCode reports whether code is a NuGet code — "NU" (any case)
|
|
// followed by one or more digits — the audit / restore advisory family.
|
|
func isNuGetAdvisoryCode(code string) bool {
|
|
if len(code) < 3 {
|
|
return false
|
|
}
|
|
if (code[0] != 'N' && code[0] != 'n') || (code[1] != 'U' && code[1] != 'u') {
|
|
return false
|
|
}
|
|
for _, r := range code[2:] {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// filterCSharpAdvisoryDiags drops NuGet *advisory* diagnostics (code NU####)
|
|
// from a C# publishDiagnostics batch and returns the survivors.
|
|
//
|
|
// Why: csharp-ls / OmniSharp build a Roslyn MSBuildWorkspace that escalates a
|
|
// NuGet audit *warning* — e.g. NU1902 "package has a known vulnerability" — to
|
|
// a fatal project-load failure and then surfaces it as a diagnostic. The
|
|
// `dotnet build` / `dotnet test` CLIs keep the same NU19xx a non-fatal warning
|
|
// and succeed, so the diagnostic is noise from the indexer's point of view
|
|
// (gortex does not act on dependency-vulnerability advisories).
|
|
//
|
|
// The filter is deliberately narrow: it matches ONLY the NU#### NuGet code
|
|
// family. Real compiler diagnostics (CS####) and analyzer warnings always pass
|
|
// through — a dropped project's genuine "unresolved type" errors are fixed by
|
|
// loading the project (the pre-restore guard), never by hiding CS codes
|
|
// here. Returns the input slice unchanged (no allocation) when nothing matches.
|
|
func filterCSharpAdvisoryDiags(diags []Diagnostic) []Diagnostic {
|
|
drop := false
|
|
for _, d := range diags {
|
|
if isNuGetAdvisoryCode(diagCodeString(d.Code)) {
|
|
drop = true
|
|
break
|
|
}
|
|
}
|
|
if !drop {
|
|
return diags
|
|
}
|
|
out := make([]Diagnostic, 0, len(diags))
|
|
for _, d := range diags {
|
|
if isNuGetAdvisoryCode(diagCodeString(d.Code)) {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// csharpRestoreTimeout bounds the pre-spawn `dotnet restore`.
|
|
const csharpRestoreTimeout = 5 * time.Minute
|
|
|
|
// csharpPreRestoreEligible reports whether the C# pre-restore should run for
|
|
// this provider: it serves C#, restore is enabled (csharpRestoreEnabled, ON by
|
|
// default), and we are spawning the server (not passively attached to an
|
|
// IDE-owned LSP, which manages its own restore).
|
|
func (p *Provider) csharpPreRestoreEligible() bool {
|
|
return p.connect == nil && p.servesCSharp() && csharpRestoreEnabled()
|
|
}
|
|
|
|
// maybeCSharpPreRestore runs `dotnet restore` with NuGet audit suppressed in
|
|
// workspaceRoot before the C# LSP starts, when csharpPreRestoreEligible.
|
|
//
|
|
// Why: csharp-ls / OmniSharp treat a NuGet audit warning (NU19xx) as a fatal
|
|
// project-load failure and drop the project; its files then have no compilation
|
|
// and the server reports false "unresolved type" errors, while `dotnet build`
|
|
// keeps NU19xx a non-fatal warning. Restoring with `-p:NuGetAudit=false` writes
|
|
// a clean project.assets.json so the workspace loads every project.
|
|
//
|
|
// On by default (CSharpRestoreEnv): gortex only indexes repositories the user
|
|
// explicitly added (never auto-discovered), and the C# server spawn already
|
|
// evaluates the project's MSBuild graph — so restore adds no execution surface
|
|
// beyond the workspace load it precedes. Best-effort: a restore failure logs
|
|
// and falls through to the normal spawn (status quo), never aborting
|
|
// enrichment; skipped on passive attach (the IDE owns restore) and when dotnet
|
|
// is not on PATH.
|
|
func (p *Provider) maybeCSharpPreRestore(workspaceRoot string) {
|
|
if !p.csharpPreRestoreEligible() {
|
|
return
|
|
}
|
|
if _, err := exec.LookPath("dotnet"); err != nil {
|
|
if p.logger != nil {
|
|
p.logger.Debug("lsp: csharp pre-restore skipped — dotnet not on PATH",
|
|
zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), csharpRestoreTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "dotnet", "restore", "-p:NuGetAudit=false")
|
|
cmd.Dir = workspaceRoot
|
|
cmd.Env = append(os.Environ(), p.env...)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
if p.logger != nil {
|
|
p.logger.Warn("lsp: csharp pre-restore failed; spawning server anyway",
|
|
zap.String("workspace", workspaceRoot),
|
|
zap.Error(err),
|
|
zap.ByteString("output", lastBytes(out, 600)),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
if p.logger != nil {
|
|
p.logger.Info("lsp: csharp pre-restore complete (NuGetAudit suppressed)",
|
|
zap.String("workspace", workspaceRoot))
|
|
}
|
|
}
|
|
|
|
// lastBytes returns up to the last n bytes of b — keeps a failed restore's
|
|
// tail (where the error sits) out of an unbounded log line.
|
|
func lastBytes(b []byte, n int) []byte {
|
|
if len(b) <= n {
|
|
return b
|
|
}
|
|
return b[len(b)-n:]
|
|
}
|
|
|
|
// ensureClient starts the LSP server if not already running, OR
|
|
// reconnects if the previous client's transport went away (e.g. the
|
|
// IDE that owned a passive-attach LSP restarted, closing the socket).
|
|
//
|
|
// For spawn-mode providers this matches the original behaviour: first
|
|
// call spawns, subsequent calls are no-ops while the subprocess lives.
|
|
// For passive (connect) mode, a dead client triggers a re-dial with
|
|
// exponential backoff, falling back to spawn only if the spec's
|
|
// Connect.FallbackSpawn is true.
|
|
func (p *Provider) ensureClient(workspaceRoot string) error {
|
|
// Liveness probe — if we have a client but its read loop has
|
|
// terminated, the server (or socket) is gone. Treat as
|
|
// disconnected: drop the dead handle and reset per-connection
|
|
// state so the next branch can build a fresh transport.
|
|
if p.client != nil {
|
|
select {
|
|
case <-p.client.Done():
|
|
p.resetForReconnect()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Reset the dynamic capability table — a fresh subprocess (or a
|
|
// fresh dialed connection) has no dynamic registrations until it
|
|
// re-announces them. Reset under the lock so any racing
|
|
// Supports() reader sees a coherent state.
|
|
p.capsMu.Lock()
|
|
p.caps = ServerCapabilities{}
|
|
p.dynamicCaps = map[string]Registration{}
|
|
p.capsMu.Unlock()
|
|
|
|
// C#: optionally `dotnet restore` (NuGet audit suppressed) before the
|
|
// server starts so its MSBuild workspace loads every project instead of
|
|
// dropping audit-warning projects and reporting false errors. On by
|
|
// default and best-effort — see maybeCSharpPreRestore / CSharpRestoreEnv.
|
|
p.maybeCSharpPreRestore(workspaceRoot)
|
|
|
|
client, err := p.dialOrSpawn(workspaceRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Wire diagnostic + reverse-RPC handlers before initialize so we
|
|
// don't lose the first publishDiagnostics burst that some servers
|
|
// emit during workspace warmup.
|
|
client.OnNotification("textDocument/publishDiagnostics",
|
|
func(_ string, params json.RawMessage) {
|
|
var pd PublishDiagnosticsParams
|
|
if err := json.Unmarshal(params, &pd); err != nil {
|
|
return
|
|
}
|
|
abs := uriToAbsPath(pd.URI)
|
|
if abs == "" {
|
|
return
|
|
}
|
|
diags := pd.Diagnostics
|
|
// C#: strip NuGet audit advisories (NU19xx) that csharp-ls /
|
|
// OmniSharp surface as diagnostics (and escalate to fatal
|
|
// project drops) — they are not code errors an indexer acts
|
|
// on. Real CS#### compiler diagnostics pass through untouched.
|
|
if p.servesCSharp() && csharpDiagFilterEnabled() {
|
|
diags = filterCSharpAdvisoryDiags(diags)
|
|
}
|
|
p.docMu.Lock()
|
|
p.lastDiag[abs] = diags
|
|
p.docMu.Unlock()
|
|
p.fanoutDiagnostics(abs, diags)
|
|
})
|
|
// Some servers (rust-analyzer, jdtls) emit progress / log messages
|
|
// — silently swallow them; they're not actionable for the indexer.
|
|
for _, m := range []string{
|
|
"$/progress", "window/logMessage", "window/showMessage",
|
|
"telemetry/event", "$/cancelRequest",
|
|
} {
|
|
client.OnNotification(m, func(_ string, _ json.RawMessage) {})
|
|
}
|
|
|
|
// Reply OK to common reverse-RPC requests so servers don't stall.
|
|
// We never *need* to mutate workspace settings — saying "applied"
|
|
// to applyEdit when we're an indexer is wrong, so we say no by
|
|
// default and let the apply-code-action path opt in explicitly.
|
|
client.OnRequest("workspace/configuration",
|
|
func(_ string, _ json.RawMessage) (any, *jsonRPCError) {
|
|
// Reply with one nil per requested item — servers that ask
|
|
// for configuration treat null as "use defaults".
|
|
return []any{nil}, nil
|
|
})
|
|
// client/registerCapability and client/unregisterCapability —
|
|
// dynamic capability announcements. Some servers send these as
|
|
// requests (with id, expecting a null ack); others send them as
|
|
// notifications. We wire both forms to the same handlers so the
|
|
// caps table converges regardless of which framing the server
|
|
// chose. See applyRegistrations / applyUnregistrations.
|
|
client.OnRequest("client/registerCapability",
|
|
func(_ string, params json.RawMessage) (any, *jsonRPCError) {
|
|
p.applyRegistrations(params)
|
|
// LSP spec: reply with null (an empty success result).
|
|
return nil, nil
|
|
})
|
|
client.OnRequest("client/unregisterCapability",
|
|
func(_ string, params json.RawMessage) (any, *jsonRPCError) {
|
|
p.applyUnregistrations(params)
|
|
return nil, nil
|
|
})
|
|
client.OnNotification("client/registerCapability",
|
|
func(_ string, params json.RawMessage) {
|
|
p.applyRegistrations(params)
|
|
})
|
|
client.OnNotification("client/unregisterCapability",
|
|
func(_ string, params json.RawMessage) {
|
|
p.applyUnregistrations(params)
|
|
})
|
|
client.OnRequest("workspace/applyEdit",
|
|
func(_ string, _ json.RawMessage) (any, *jsonRPCError) {
|
|
// Default: refuse. The apply-code-action path swaps this
|
|
// handler before issuing executeCommand so server-driven
|
|
// applies land on disk via WriteWorkspaceEdit.
|
|
return ApplyWorkspaceEditResponse{Applied: false, FailureReason: "applies are routed through gortex"}, nil
|
|
})
|
|
|
|
initParams := InitializeParams{
|
|
ProcessID: os.Getpid(),
|
|
RootURI: pathToURI(workspaceRoot),
|
|
WorkspaceFolders: buildWorkspaceFolders(workspaceRoot, p.workspaceFolders),
|
|
Capabilities: ClientCapabilities{
|
|
Workspace: &WorkspaceClientCapabilities{
|
|
ApplyEdit: true,
|
|
WorkspaceEdit: &WorkspaceEditClientCapabilities{
|
|
DocumentChanges: true,
|
|
ResourceOperations: []string{"create", "rename", "delete"},
|
|
},
|
|
ExecuteCommand: &ExecuteCommandCapability{DynamicRegistration: true},
|
|
WorkspaceFolders: true,
|
|
Configuration: true,
|
|
},
|
|
TextDocument: TextDocumentClientCapabilities{
|
|
Synchronization: &SynchronizationCapability{DynamicRegistration: true},
|
|
Implementation: &ImplementationCapability{DynamicRegistration: true},
|
|
References: &ReferencesCapability{DynamicRegistration: true},
|
|
Definition: &DefinitionCapability{DynamicRegistration: true},
|
|
Hover: &HoverCapability{ContentFormat: []string{"plaintext"}},
|
|
CallHierarchy: &CallHierarchyCapability{DynamicRegistration: true},
|
|
TypeHierarchy: &TypeHierarchyCapability{DynamicRegistration: true},
|
|
CodeAction: &CodeActionCapability{
|
|
DynamicRegistration: true,
|
|
CodeActionLiteralSupport: &CodeActionLiteralSupport{
|
|
CodeActionKind: CodeActionKindCapability{
|
|
ValueSet: []string{
|
|
CodeActionKindEmpty,
|
|
CodeActionKindQuickFix,
|
|
CodeActionKindRefactor,
|
|
CodeActionKindRefactorExtract,
|
|
CodeActionKindRefactorInline,
|
|
CodeActionKindRefactorRewrite,
|
|
CodeActionKindSource,
|
|
CodeActionKindSourceOrganizeImports,
|
|
CodeActionKindSourceFixAll,
|
|
},
|
|
},
|
|
},
|
|
IsPreferredSupport: true,
|
|
},
|
|
PublishDiagnostics: &PublishDiagnosticsCapability{
|
|
RelatedInformation: true,
|
|
VersionSupport: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
// Pass server-specific InitializationOptions (e.g. Maven/Gradle import
|
|
// settings for jdtls, or a per-repo storagePath for a resolved-
|
|
// alternative intelephense) when the provider was built from a ServerSpec.
|
|
if opts := p.effectiveInitOptions(workspaceRoot); len(opts) > 0 {
|
|
initParams.InitializationOptions = opts
|
|
}
|
|
|
|
var initResult InitializeResult
|
|
if err := client.Call("initialize", initParams, &initResult); err != nil {
|
|
_ = client.Shutdown()
|
|
return fmt.Errorf("initialize: %w", err)
|
|
}
|
|
|
|
// Snapshot the server's static capabilities so Supports() can
|
|
// answer "did the server advertise this at initialize time?".
|
|
// Dynamic registrations may arrive any time after this point.
|
|
p.capsMu.Lock()
|
|
p.caps = initResult.Capabilities
|
|
p.capsMu.Unlock()
|
|
|
|
// Send initialized notification.
|
|
if err := client.Notify("initialized", struct{}{}); err != nil {
|
|
_ = client.Shutdown()
|
|
return fmt.Errorf("initialized: %w", err)
|
|
}
|
|
|
|
// The (possibly slow) cold-workspace load is done — bound every
|
|
// subsequent request so a server that wedges mid-session can no
|
|
// longer block an enrichment Call forever. See lspCallTimeout.
|
|
client.SetCallTimeout(lspCallTimeout())
|
|
|
|
p.client = client
|
|
return nil
|
|
}
|
|
|
|
// WaitReady implements semantic.ReadinessProber for the Roslyn / MSBuild-class
|
|
// C# servers (csharp-ls, OmniSharp). They answer `initialize` quickly but keep
|
|
// loading the solution in the background and serve empty results until it
|
|
// finishes — the pathology where the enrichment deadline elapses mid-load and
|
|
// the pass lands zero edges ("completed in 8s, 0 coverage"). WaitReady brings
|
|
// the client up (spawn, optional `dotnet restore`, `initialize`) and then blocks
|
|
// on a readiness probe until the solution's compilation is live, so the Manager
|
|
// starts the per-repo deadline only once real queries will actually resolve.
|
|
//
|
|
// The probe polls workspace/symbol until it returns a match; on the load-timeout
|
|
// it reports semantic.ErrWorkspaceNotReady so the Manager skips the sweep with an
|
|
// honest not-ready state instead of running it against a still-empty server.
|
|
// Every other server answers once initialized and does not implement
|
|
// ReadinessProber, so it never waits. Runs synchronously; ctx bounds the
|
|
// readiness budget and short-circuits an already-cancelled one.
|
|
func (p *Provider) WaitReady(ctx context.Context, repoRoot string) error {
|
|
if !p.servesCSharp() {
|
|
return nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
absRoot, err := filepath.Abs(repoRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := p.ensureClient(absRoot); err != nil {
|
|
return err
|
|
}
|
|
return p.waitForCSharpSolutionLoad(ctx, absRoot)
|
|
}
|
|
|
|
// csharpSolutionLoadTimeout bounds how long WaitReady polls for the Roslyn
|
|
// solution load to finish after `initialize` returns. csharpReadinessProbeInterval
|
|
// spaces the polls. Both are var (not const) so tests can shrink them.
|
|
var (
|
|
csharpSolutionLoadTimeout = 120 * time.Second
|
|
csharpReadinessProbeInterval = 750 * time.Millisecond
|
|
)
|
|
|
|
// waitForCSharpSolutionLoad blocks until the server's compilation can answer a
|
|
// workspace/symbol probe (the solution finished loading) or the load-timeout
|
|
// elapses. Bounded by the smaller of ctx and csharpSolutionLoadTimeout.
|
|
func (p *Provider) waitForCSharpSolutionLoad(ctx context.Context, repoRoot string) error {
|
|
query, ok := csharpProbeQuery(repoRoot)
|
|
if !ok {
|
|
// No C# declaration to probe with — there is nothing to enrich either,
|
|
// so treat the workspace as ready and let the (empty) pass complete.
|
|
return nil
|
|
}
|
|
loadCtx, cancel := context.WithTimeout(ctx, csharpSolutionLoadTimeout)
|
|
defer cancel()
|
|
return p.pollWorkspaceReady(loadCtx, query)
|
|
}
|
|
|
|
// pollWorkspaceReady polls workspace/symbol until it returns at least one match
|
|
// — the signal the compilation is live — or ctx expires. On expiry it wraps
|
|
// semantic.ErrWorkspaceNotReady so the Manager can record an honest not-ready
|
|
// state and skip the sweep. A transport / RPC error counts as "still loading",
|
|
// not fatal, so a server mid-load is retried rather than abandoned.
|
|
func (p *Provider) pollWorkspaceReady(ctx context.Context, query string) error {
|
|
ticker := time.NewTicker(csharpReadinessProbeInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
if p.workspaceSymbolNonEmpty(query) {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("%w: workspace/symbol %q stayed empty", semantic.ErrWorkspaceNotReady, query)
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// workspaceSymbolNonEmpty issues one workspace/symbol request and reports
|
|
// whether the server returned any symbol. A nil client or any error reads as
|
|
// "not ready yet" rather than a hard failure.
|
|
func (p *Provider) workspaceSymbolNonEmpty(query string) bool {
|
|
client := p.client
|
|
if client == nil {
|
|
return false
|
|
}
|
|
var syms []json.RawMessage
|
|
params := struct {
|
|
Query string `json:"query"`
|
|
}{Query: query}
|
|
if err := client.Call("workspace/symbol", params, &syms); err != nil {
|
|
return false
|
|
}
|
|
return len(syms) > 0
|
|
}
|
|
|
|
var csharpTypeDeclRe = regexp.MustCompile(`\b(?:class|interface|struct|enum)\s+([A-Za-z_]\w*)|\brecord\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*)`)
|
|
var csharpNamespaceRe = regexp.MustCompile(`\bnamespace\s+([A-Za-z_][\w.]*)`)
|
|
|
|
// csharpDeclName extracts a probeable identifier from C# source: a type name
|
|
// (class / interface / struct / record / enum) when present, else the leaf of a
|
|
// namespace declaration. Returns "" when the source has neither.
|
|
func csharpDeclName(src []byte) string {
|
|
if m := csharpTypeDeclRe.FindSubmatch(src); m != nil {
|
|
if len(m[1]) > 0 {
|
|
return string(m[1])
|
|
}
|
|
if len(m[2]) > 0 {
|
|
return string(m[2])
|
|
}
|
|
}
|
|
if m := csharpNamespaceRe.FindSubmatch(src); m != nil {
|
|
ns := string(m[1])
|
|
if i := strings.LastIndexByte(ns, '.'); i >= 0 {
|
|
ns = ns[i+1:]
|
|
}
|
|
return ns
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// csharpProbeQuery walks repoRoot for the first C# type/namespace declaration
|
|
// and returns its name — a workspace/symbol query token guaranteed to match once
|
|
// the solution loads. ok=false when the tree holds no probeable declaration
|
|
// (in which case there is nothing to enrich, so readiness is moot). The walk is
|
|
// capped so a huge monorepo can't turn readiness into a full-tree scan.
|
|
func csharpProbeQuery(repoRoot string) (string, bool) {
|
|
var found string
|
|
scanned := 0
|
|
_ = filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
name := d.Name()
|
|
if path != repoRoot && (strings.HasPrefix(name, ".") || name == "bin" || name == "obj" || name == "node_modules") {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(d.Name(), ".cs") {
|
|
return nil
|
|
}
|
|
scanned++
|
|
if scanned > 500 {
|
|
return fs.SkipAll
|
|
}
|
|
src, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return nil
|
|
}
|
|
if name := csharpDeclName(src); name != "" {
|
|
found = name
|
|
return fs.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
return found, found != ""
|
|
}
|
|
|
|
// fanoutDiagnostics wakes everyone who called WaitForDiagnostics for
|
|
// this absPath AND invokes the persistent hook installed via
|
|
// SetDiagnosticsHook (if any). Runs with no provider lock held.
|
|
//
|
|
// The hook MUST NOT block — this method runs on the LSP client's
|
|
// message-pump goroutine. The MCP-level wiring uses
|
|
// `SendNotificationToAllClients` which is non-blocking by design (the
|
|
// SDK drops to an error hook when a session's notification channel is
|
|
// full).
|
|
func (p *Provider) fanoutDiagnostics(absPath string, diags []Diagnostic) {
|
|
p.diagWaitersMu.Lock()
|
|
waiters := p.diagWaiters[absPath]
|
|
delete(p.diagWaiters, absPath)
|
|
p.diagWaitersMu.Unlock()
|
|
for _, ch := range waiters {
|
|
select {
|
|
case ch <- diags:
|
|
default:
|
|
}
|
|
}
|
|
p.diagHookMu.RLock()
|
|
hook := p.diagHook
|
|
p.diagHookMu.RUnlock()
|
|
if hook != nil {
|
|
hook(absPath, diags)
|
|
}
|
|
}
|
|
|
|
// SetDiagnosticsHook installs a persistent callback invoked for every
|
|
// `textDocument/publishDiagnostics` the LSP server emits for this
|
|
// provider. Pass nil to detach. The Router uses this to forward LSP
|
|
// diagnostics to MCP clients via `notifications/diagnostics`.
|
|
//
|
|
// The hook MUST NOT block — see fanoutDiagnostics doc.
|
|
func (p *Provider) SetDiagnosticsHook(hook func(absPath string, diags []Diagnostic)) {
|
|
p.diagHookMu.Lock()
|
|
p.diagHook = hook
|
|
p.diagHookMu.Unlock()
|
|
}
|
|
|
|
// DiagnosticsSnapshot returns a copy of the most recent
|
|
// publishDiagnostics payload per absolute path. Used to replay current
|
|
// state to a freshly-subscribed MCP client so it doesn't have to wait
|
|
// for the next edit to learn what's currently broken.
|
|
//
|
|
// The map is a defensive copy — callers may mutate freely.
|
|
func (p *Provider) DiagnosticsSnapshot() map[string][]Diagnostic {
|
|
p.docMu.RLock()
|
|
defer p.docMu.RUnlock()
|
|
out := make(map[string][]Diagnostic, len(p.lastDiag))
|
|
for path, diags := range p.lastDiag {
|
|
cp := make([]Diagnostic, len(diags))
|
|
copy(cp, diags)
|
|
out[path] = cp
|
|
}
|
|
return out
|
|
}
|
|
|
|
// uriToAbsPath converts a file:// URI to an absolute filesystem path.
|
|
// Returns "" for non-file URIs or malformed input.
|
|
func uriToAbsPath(uri string) string {
|
|
return lspuri.URIToAbsPath(uri)
|
|
}
|
|
|
|
// openDocument sends textDocument/didOpen for a file. Tracks version
|
|
// 1 in docVersions so a later didChange can monotonically bump it.
|
|
// Idempotent — a second call to openDocument with the same path is a
|
|
// no-op.
|
|
func (p *Provider) openDocument(repoRoot, relPath string) error {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
p.docMu.Lock()
|
|
if p.openDocs[absPath] {
|
|
p.docMu.Unlock()
|
|
return nil
|
|
}
|
|
p.docMu.Unlock()
|
|
|
|
content, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.sourceCache == nil {
|
|
p.sourceCache = map[string][]byte{}
|
|
}
|
|
p.sourceCache[absPath] = content
|
|
|
|
langID := p.languageIDFor(absPath)
|
|
|
|
if err := p.client.Notify("textDocument/didOpen", DidOpenTextDocumentParams{
|
|
TextDocument: TextDocumentItem{
|
|
URI: pathToURI(absPath),
|
|
LanguageID: langID,
|
|
Version: 1,
|
|
Text: string(content),
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
p.docMu.Lock()
|
|
p.openDocs[absPath] = true
|
|
p.docVersions[absPath] = 1
|
|
p.docMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// languageIDFor picks the LSP `languageId` to send in didOpen. When
|
|
// the provider was built from a ServerSpec, the spec's per-extension
|
|
// table wins; otherwise we fall back to the first configured language.
|
|
// Final fallback is the file's extension stripped of its leading dot.
|
|
func (p *Provider) languageIDFor(absPath string) string {
|
|
if p.spec != nil {
|
|
ext := strings.ToLower(filepath.Ext(absPath))
|
|
if id, ok := p.spec.LanguageIDs[ext]; ok && id != "" {
|
|
return id
|
|
}
|
|
}
|
|
if len(p.languages) > 0 {
|
|
return p.languages[0]
|
|
}
|
|
if ext := strings.ToLower(filepath.Ext(absPath)); ext != "" {
|
|
return strings.TrimPrefix(ext, ".")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// changeDocument sends textDocument/didChange with a full-text replace
|
|
// and bumps the document's version monotonically.
|
|
func (p *Provider) changeDocument(absPath, newText string) error {
|
|
p.docMu.Lock()
|
|
v := p.docVersions[absPath] + 1
|
|
p.docVersions[absPath] = v
|
|
p.docMu.Unlock()
|
|
if p.sourceCache == nil {
|
|
p.sourceCache = map[string][]byte{}
|
|
}
|
|
p.sourceCache[absPath] = []byte(newText)
|
|
return p.client.Notify("textDocument/didChange", DidChangeTextDocumentParams{
|
|
TextDocument: VersionedTextDocumentIdentifier{
|
|
URI: pathToURI(absPath),
|
|
Version: v,
|
|
},
|
|
ContentChanges: []TextDocumentContentChangeEvent{{Text: newText}},
|
|
})
|
|
}
|
|
|
|
// closeDocument sends textDocument/didClose. Idempotent.
|
|
func (p *Provider) closeDocument(absPath string) error {
|
|
p.docMu.Lock()
|
|
if !p.openDocs[absPath] {
|
|
p.docMu.Unlock()
|
|
return nil
|
|
}
|
|
delete(p.openDocs, absPath)
|
|
delete(p.docVersions, absPath)
|
|
p.docMu.Unlock()
|
|
// Drop this file's cached bytes too. getSource tolerates a miss
|
|
// (falls back to col=0) and the next openDocument repopulates, so
|
|
// without this an interactive session that navigates thousands of
|
|
// files would pin every one's contents until the daemon exits.
|
|
delete(p.sourceCache, absPath)
|
|
return p.client.Notify("textDocument/didClose", DidCloseTextDocumentParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
})
|
|
}
|
|
|
|
// PushSimulatedContent sends a textDocument/didChange carrying
|
|
// `newText` as a full-text replacement, so the LSP server re-analyses
|
|
// the file as if it contained that buffer without anything ever
|
|
// touching disk. Used by the simulation engine (preview_edit /
|
|
// simulate_chain) to round-trip diagnostics for hypothetical edits.
|
|
// The caller is responsible for restoring the original on-disk
|
|
// content with a second PushSimulatedContent at simulation
|
|
// completion — otherwise other sessions that share this Provider
|
|
// will read diagnostics that reflect the simulated state instead of
|
|
// the saved file. EnsureFileOpen must be called first so the server
|
|
// has the document in its open-documents set; calling on an unopened
|
|
// path returns the underlying transport error.
|
|
func (p *Provider) PushSimulatedContent(absPath, newText string) error {
|
|
return p.changeDocument(absPath, newText)
|
|
}
|
|
|
|
// LastDiagnostics returns the most recent diagnostics published for a
|
|
// file. Returns nil + false when the server has not (yet) emitted
|
|
// diagnostics for that path.
|
|
func (p *Provider) LastDiagnostics(absPath string) ([]Diagnostic, bool) {
|
|
p.docMu.RLock()
|
|
defer p.docMu.RUnlock()
|
|
d, ok := p.lastDiag[absPath]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out := make([]Diagnostic, len(d))
|
|
copy(out, d)
|
|
return out, true
|
|
}
|
|
|
|
// WaitForDiagnostics blocks until the server publishes the next
|
|
// publishDiagnostics for absPath, or the timeout elapses (returning the
|
|
// last known diagnostics if any). Callers register their interest
|
|
// before triggering the change that will cause the publish, otherwise
|
|
// they may miss the event.
|
|
func (p *Provider) WaitForDiagnostics(absPath string, timeout time.Duration) []Diagnostic {
|
|
ch := make(chan []Diagnostic, 1)
|
|
p.diagWaitersMu.Lock()
|
|
p.diagWaiters[absPath] = append(p.diagWaiters[absPath], ch)
|
|
p.diagWaitersMu.Unlock()
|
|
select {
|
|
case d := <-ch:
|
|
return d
|
|
case <-time.After(timeout):
|
|
// Drain & remove our waiter so we don't leak.
|
|
p.diagWaitersMu.Lock()
|
|
var kept []chan []Diagnostic
|
|
for _, w := range p.diagWaiters[absPath] {
|
|
if w != ch {
|
|
kept = append(kept, w)
|
|
}
|
|
}
|
|
p.diagWaiters[absPath] = kept
|
|
p.diagWaitersMu.Unlock()
|
|
if d, ok := p.LastDiagnostics(absPath); ok {
|
|
return d
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Client exposes the underlying LSP client for advanced callers (e.g.
|
|
// the daemon router). Returns nil before ensureClient succeeds.
|
|
func (p *Provider) Client() *Client { return p.client }
|
|
|
|
// EnsureClient is the exported form of ensureClient — it spawns the
|
|
// LSP server (idempotent) so callers that want diagnostics or code
|
|
// actions outside an Enrich pass can prime the connection on demand.
|
|
func (p *Provider) EnsureClient(workspaceRoot string) error {
|
|
abs, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.ensureClient(abs)
|
|
}
|
|
|
|
// EnsureFileOpen makes sure the document is opened on the server (with
|
|
// version 1) so request methods that take a position can proceed.
|
|
func (p *Provider) EnsureFileOpen(repoRoot, relPath string) error {
|
|
abs, err := filepath.Abs(repoRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.openDocument(abs, relPath)
|
|
}
|
|
|
|
// EnrichNode lazily enriches a single symbol node with an LSP-grade
|
|
// semantic_type (and return_type for callables), on demand. It is the
|
|
// per-symbol counterpart to the whole-repo Enrich pass: when the synchronous
|
|
// LSP sweep is off (the default), a query tool that needs a precise type for
|
|
// one symbol calls this to fault in exactly that symbol's hover — one LSP
|
|
// round-trip, not a repo sweep — and the stamp persists in the graph so a
|
|
// second query is free.
|
|
//
|
|
// Returns (true, nil) when a type was stamped, (false, nil) when the server had
|
|
// no type at the node's position (nothing is written), and (false, err) on a
|
|
// transport failure. The file is opened on the server first (idempotent).
|
|
// ConfirmSymbolRefs confirms the incoming references to ONE symbol on demand.
|
|
// It prepares a call hierarchy at the symbol's definition and, for every
|
|
// server-verified incoming call, mints or promotes an lsp_resolved edge —
|
|
// the per-symbol unit of the whole-repo confirm + call-hierarchy sweep. A
|
|
// find_usages / get_callers answer for symbol S needs S's callers confirmed,
|
|
// not the whole repo's, so this lets lazy enrichment restore compiler-grade
|
|
// usages accuracy at query time without paying a cold-path sweep. It reuses
|
|
// recordHierarchyCall, so every hard-won correctness rule (callable-kind
|
|
// matching, per-site promotion, declaration-identity) applies unchanged.
|
|
//
|
|
// Scoped to callable nodes (the call-hierarchy unit); returns (0, nil) for
|
|
// other kinds or a server without call-hierarchy. LSP round-trips run
|
|
// unlocked; the graph mutations are batched under the resolve mutex.
|
|
func (p *Provider) ConfirmSymbolRefs(g graph.Store, repoRoot string, n *graph.Node) (int, error) {
|
|
if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) {
|
|
return 0, nil
|
|
}
|
|
if !p.Supports("textDocument/prepareCallHierarchy") {
|
|
return 0, nil
|
|
}
|
|
line, ok := lspLine(n)
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
rel := nodeRelPath(n)
|
|
if err := p.EnsureFileOpen(repoRoot, rel); err != nil {
|
|
return 0, err
|
|
}
|
|
absRoot, err := filepath.Abs(repoRoot)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
col := 0
|
|
if src := p.getSource(repoRoot, rel); src != nil {
|
|
col = identifierColumn(src, n.StartLine, n.Name)
|
|
}
|
|
items, err := p.prepareCallHierarchy(repoRoot, rel, line, col)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// LSP round-trips first, unlocked.
|
|
type hop struct {
|
|
other CallHierarchyItem
|
|
ranges []Range
|
|
}
|
|
var hops []hop
|
|
for _, item := range items {
|
|
ins, ierr := p.incomingCalls(item)
|
|
if ierr != nil {
|
|
continue
|
|
}
|
|
for _, ic := range ins {
|
|
hops = append(hops, hop{other: ic.From, ranges: ic.FromRanges})
|
|
}
|
|
}
|
|
if len(hops) == 0 {
|
|
return 0, nil
|
|
}
|
|
// Graph mutations under the resolve lock, batched.
|
|
result := &semantic.EnrichResult{Provider: p.Name()}
|
|
rmu := g.ResolveMutex()
|
|
rmu.Lock()
|
|
for _, h := range hops {
|
|
p.recordHierarchyCall(g, n.RepoPrefix, absRoot, n, h.other, false, h.ranges, result)
|
|
}
|
|
rmu.Unlock()
|
|
return result.EdgesConfirmed + result.EdgesAdded, nil
|
|
}
|
|
|
|
func (p *Provider) EnrichNode(g graph.Store, repoRoot string, n *graph.Node) (bool, error) {
|
|
if n == nil {
|
|
return false, nil
|
|
}
|
|
line, ok := lspLine(n)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
rel := nodeRelPath(n)
|
|
if err := p.EnsureFileOpen(repoRoot, rel); err != nil {
|
|
return false, err
|
|
}
|
|
col := 0
|
|
if src := p.getSource(repoRoot, rel); src != nil {
|
|
col = identifierColumn(src, n.StartLine, n.Name)
|
|
}
|
|
hr, err := p.hoverWith(p.client, repoRoot, rel, line, col)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if hr == nil {
|
|
return false, nil
|
|
}
|
|
typeInfo := extractTypeFromHover(hr.Contents.Value)
|
|
if typeInfo == "" {
|
|
return false, nil
|
|
}
|
|
semantic.EnrichNodeMeta(n, "semantic_type", typeInfo, p.Name())
|
|
g.AddBatch([]*graph.Node{n}, nil)
|
|
return true, nil
|
|
}
|
|
|
|
// getSource returns cached file content from the most recent
|
|
// openDocument call. Returns nil when not cached — callers fall
|
|
// back to col=0 then.
|
|
func (p *Provider) getSource(repoRoot, relPath string) []byte {
|
|
if p.sourceCache == nil {
|
|
return nil
|
|
}
|
|
return p.sourceCache[filepath.Join(repoRoot, relPath)]
|
|
}
|
|
|
|
// hoverWith issues textDocument/hover against an explicit client.
|
|
// PURPOSE — race-free per-goroutine hover during concurrent enrichment.
|
|
// RATIONALE — enrichment goroutines pass the client they captured
|
|
// atomically, so a concurrent reconnect that swaps p.client never races
|
|
// an in-flight hover (the goroutine holds its own pointer value).
|
|
// KEYWORDS — lsp, hover, concurrency, reconnect
|
|
func (p *Provider) hoverWith(c *Client, repoRoot, relPath string, line, col int) (*HoverResult, error) {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := HoverParams{
|
|
TextDocumentPositionParams: TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
},
|
|
}
|
|
|
|
p.reqStats.hovers.Add(1)
|
|
var result HoverResult
|
|
if err := c.Call("textDocument/hover", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Contents.Value == "" {
|
|
return nil, nil
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// enrichOpenDoc sends a bare textDocument/didOpen against an explicit
|
|
// client without touching the shared openDocs / sourceCache tables.
|
|
// PURPOSE — per-goroutine document open for the concurrent hover phase.
|
|
// RATIONALE — each enrichment goroutine owns its document's lifecycle, so
|
|
// it must not contend on the provider-wide doc maps; the matching close
|
|
// is enrichCloseDoc, always deferred.
|
|
// KEYWORDS — lsp, didOpen, enrichment, per-goroutine
|
|
func (p *Provider) enrichOpenDoc(c *Client, absPath string, content []byte) error {
|
|
return c.Notify("textDocument/didOpen", DidOpenTextDocumentParams{
|
|
TextDocument: TextDocumentItem{
|
|
URI: pathToURI(absPath),
|
|
LanguageID: p.languageIDFor(absPath),
|
|
Version: 1,
|
|
Text: string(content),
|
|
},
|
|
})
|
|
}
|
|
|
|
// enrichCloseDoc sends a bare textDocument/didClose against an explicit
|
|
// client — the counterpart to enrichOpenDoc.
|
|
// PURPOSE — release the server's per-document state immediately after a
|
|
// node's hover so simultaneously-open docs stay capped at maxParallel.
|
|
// RATIONALE — bulk-holding documents open was the enrichment OOM root
|
|
// cause; closing eagerly per goroutine bounds heap pressure.
|
|
// KEYWORDS — lsp, didClose, enrichment, lifecycle
|
|
func (p *Provider) enrichCloseDoc(c *Client, absPath string) error {
|
|
return c.Notify("textDocument/didClose", DidCloseTextDocumentParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
})
|
|
}
|
|
|
|
// isServerExitError reports whether err signals that the language server
|
|
// process / transport is gone, so the enrichment loop should reconnect
|
|
// rather than keep hammering a dead session.
|
|
// PURPOSE — classify hover errors into "server died" vs "ordinary".
|
|
// RATIONALE — only transport/exit failures warrant a reconnect; protocol
|
|
// errors (e.g. an internal-error JSON-RPC reply) must not.
|
|
// KEYWORDS — lsp, reconnect, server-exit, error-classification
|
|
func isServerExitError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
for _, marker := range []string{
|
|
"LSP server exited",
|
|
"client is closed",
|
|
"broken pipe",
|
|
"connection reset",
|
|
"use of closed network connection",
|
|
} {
|
|
if strings.Contains(msg, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// reconnectWithBackoff rebuilds the LSP client after the server exits
|
|
// mid-enrichment. It retries connectOnce with exponential backoff
|
|
// (dialBackoffStart → maxDialBackoff) up to maxReconnectAttempts and
|
|
// returns the fresh client, or an error if every attempt failed.
|
|
// PURPOSE — automatic recovery so one mid-flight crash doesn't fail every
|
|
// remaining hover.
|
|
// RATIONALE — callers hold reconnectMu, so exactly one reconnection runs
|
|
// at a time; backoff prevents a tight retry loop against a persistently
|
|
// dead server.
|
|
// KEYWORDS — lsp, reconnect, backoff, recovery
|
|
// maxEnrichReconnectCycles caps how many times a single enrichment pass will
|
|
// reconnect to a provider that keeps exiting mid-request. reconnectWithBackoff
|
|
// (below) caps the connection retries within ONE cycle; this caps the number of
|
|
// cycles, so a server that reconnects cleanly but crashes again on the next
|
|
// request can't pin the pass in an endless crash -> reconnect -> crash loop.
|
|
const maxEnrichReconnectCycles = 5
|
|
|
|
func (p *Provider) reconnectWithBackoff(absRoot string) (*Client, error) {
|
|
const maxReconnectAttempts = 5
|
|
backoff := p.dialBackoffStart
|
|
if backoff <= 0 {
|
|
backoff = dialBackoffStart
|
|
}
|
|
maxBackoff := p.maxDialBackoff
|
|
if maxBackoff <= 0 {
|
|
maxBackoff = maxDialBackoff
|
|
}
|
|
var lastErr error
|
|
for attempt := 1; attempt <= maxReconnectAttempts; attempt++ {
|
|
p.reconnectAttempts.Add(1)
|
|
p.logger.Warn("LSP enrich: reconnecting after server exit",
|
|
zap.Int("attempt", attempt),
|
|
zap.Int("max_attempts", maxReconnectAttempts),
|
|
)
|
|
if err := p.connectOnce(absRoot); err != nil {
|
|
lastErr = err
|
|
p.logger.Warn("LSP enrich: reconnect attempt failed",
|
|
zap.Int("attempt", attempt), zap.Error(err))
|
|
time.Sleep(backoff)
|
|
if backoff *= 2; backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
continue
|
|
}
|
|
p.logger.Info("LSP enrich: reconnected", zap.Int("attempt", attempt))
|
|
return p.client, nil
|
|
}
|
|
return nil, fmt.Errorf("LSP reconnect failed after %d attempts: %w", maxReconnectAttempts, lastErr)
|
|
}
|
|
|
|
// findImplementations queries textDocument/implementation.
|
|
func (p *Provider) findImplementations(repoRoot, relPath string, line, col int) ([]Location, error) {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := ImplementationParams{
|
|
TextDocumentPositionParams: TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
},
|
|
}
|
|
|
|
p.reqStats.implementations.Add(1)
|
|
var locations []Location
|
|
if err := p.client.Call("textDocument/implementation", params, &locations); err != nil {
|
|
return nil, err
|
|
}
|
|
return locations, nil
|
|
}
|
|
|
|
// CodeActionsRequest carries the params for a single
|
|
// textDocument/codeAction call.
|
|
type CodeActionsRequest struct {
|
|
// AbsPath is the absolute path to the file the cursor is in.
|
|
AbsPath string
|
|
// Range narrows the request. Pass {} for the whole file.
|
|
Range Range
|
|
// Diagnostics is the set of diagnostics the actions should
|
|
// address — typically a recent slice from LastDiagnostics.
|
|
Diagnostics []Diagnostic
|
|
// Only restricts the kind of actions returned (e.g.
|
|
// CodeActionKindQuickFix, CodeActionKindSourceOrganizeImports).
|
|
Only []string
|
|
}
|
|
|
|
// GetCodeActions issues textDocument/codeAction and returns a unified
|
|
// list of CodeActionOrCommand. The provider must already have opened
|
|
// the document via EnsureFileOpen before calling this.
|
|
func (p *Provider) GetCodeActions(req CodeActionsRequest) ([]CodeActionOrCommand, error) {
|
|
if p.client == nil {
|
|
return nil, fmt.Errorf("LSP client not initialized")
|
|
}
|
|
params := CodeActionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(req.AbsPath)},
|
|
Range: req.Range,
|
|
Context: CodeActionContext{
|
|
Diagnostics: req.Diagnostics,
|
|
Only: req.Only,
|
|
TriggerKind: 1, // Invoked.
|
|
},
|
|
}
|
|
var raw []json.RawMessage
|
|
if err := p.client.Call("textDocument/codeAction", params, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]CodeActionOrCommand, 0, len(raw))
|
|
for _, item := range raw {
|
|
var u CodeActionOrCommand
|
|
if err := json.Unmarshal(item, &u); err != nil {
|
|
continue
|
|
}
|
|
// Legacy Command form has the shape {title, command, arguments}.
|
|
// CodeAction literal has {title, kind?, edit?, command?, ...}.
|
|
// json.Unmarshal handles both with the unified struct above.
|
|
out = append(out, u)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ResolveCodeAction calls codeAction/resolve. Some servers (rust-
|
|
// analyzer, jdtls) defer the heavy WorkspaceEdit computation until
|
|
// resolve to keep the initial codeAction call cheap.
|
|
func (p *Provider) ResolveCodeAction(action CodeActionOrCommand) (CodeActionOrCommand, error) {
|
|
if p.client == nil {
|
|
return action, fmt.Errorf("LSP client not initialized")
|
|
}
|
|
var resolved CodeActionOrCommand
|
|
if err := p.client.Call("codeAction/resolve", action, &resolved); err != nil {
|
|
return action, err
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
// ExecuteCommand issues workspace/executeCommand. Used by the
|
|
// apply-code-action path when a CodeAction has only a Command
|
|
// (legacy) form.
|
|
func (p *Provider) ExecuteCommand(cmd Command) (json.RawMessage, error) {
|
|
if p.client == nil {
|
|
return nil, fmt.Errorf("LSP client not initialized")
|
|
}
|
|
params := ExecuteCommandParams{Command: cmd.Command, Arguments: cmd.Arguments}
|
|
var result json.RawMessage
|
|
if err := p.client.Call("workspace/executeCommand", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// findReferences queries textDocument/references.
|
|
func (p *Provider) findReferences(repoRoot, relPath string, line, col int) ([]Location, error) {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := ReferenceParams{
|
|
TextDocumentPositionParams: TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
},
|
|
Context: ReferenceContext{IncludeDeclaration: false},
|
|
}
|
|
|
|
p.reqStats.references.Add(1)
|
|
var locations []Location
|
|
if err := p.client.Call("textDocument/references", params, &locations); err != nil {
|
|
return nil, err
|
|
}
|
|
return locations, nil
|
|
}
|
|
|
|
// FindDefinition queries textDocument/definition with a per-call
|
|
// timeout so a stalled LSP can't block the resolve-time hot path.
|
|
// Returns the locations reported by the server (typically one) or
|
|
// an error / empty slice on timeout, no-result, or transport failure.
|
|
//
|
|
// repoRoot is the absolute workspace root; relPath is repo-relative.
|
|
// (line, col) are 0-based, matching LSP convention.
|
|
func (p *Provider) FindDefinition(repoRoot, relPath string, line, col int, timeout time.Duration) ([]Location, error) {
|
|
if p.client == nil {
|
|
return nil, fmt.Errorf("LSP client not initialised")
|
|
}
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
}
|
|
p.reqStats.definitions.Add(1)
|
|
|
|
type result struct {
|
|
locations []Location
|
|
err error
|
|
}
|
|
ch := make(chan result, 1)
|
|
go func() {
|
|
// Tsserver replies with either a single Location, an array of
|
|
// Location, or null. The unified handling: try array first
|
|
// (most common), fall back to single Location on unmarshal
|
|
// error.
|
|
var raw json.RawMessage
|
|
if err := p.client.Call("textDocument/definition", params, &raw); err != nil {
|
|
ch <- result{nil, err}
|
|
return
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
ch <- result{nil, nil}
|
|
return
|
|
}
|
|
var locs []Location
|
|
if err := json.Unmarshal(raw, &locs); err == nil {
|
|
ch <- result{locs, nil}
|
|
return
|
|
}
|
|
var single Location
|
|
if err := json.Unmarshal(raw, &single); err == nil {
|
|
ch <- result{[]Location{single}, nil}
|
|
return
|
|
}
|
|
ch <- result{nil, fmt.Errorf("unexpected definition response shape")}
|
|
}()
|
|
|
|
if timeout <= 0 {
|
|
r := <-ch
|
|
return r.locations, r.err
|
|
}
|
|
select {
|
|
case r := <-ch:
|
|
return r.locations, r.err
|
|
case <-time.After(timeout):
|
|
return nil, fmt.Errorf("textDocument/definition: timeout after %s", timeout)
|
|
}
|
|
}
|
|
|
|
// IdentifierColumn is the exported form of the package-internal
|
|
// identifierColumn helper. Resolve-time callers (the resolver's LSP
|
|
// hot path) need the 0-based column for a given identifier on a
|
|
// given 1-based line to satisfy LSP servers that require the cursor
|
|
// to sit on the identifier.
|
|
func IdentifierColumn(src []byte, oneBasedLine int, name string) int {
|
|
return identifierColumn(src, oneBasedLine, name)
|
|
}
|
|
|
|
// Source returns the cached source for relPath under repoRoot, or
|
|
// nil when the document has not been opened. Exported so the
|
|
// resolve-time helper can compute identifier columns without
|
|
// re-reading the file from disk.
|
|
func (p *Provider) Source(repoRoot, relPath string) []byte {
|
|
return p.getSource(repoRoot, relPath)
|
|
}
|
|
|
|
// recordHierarchyCall lands one call-hierarchy hop into the graph.
|
|
// asOutgoing=true means "this node calls other"; false means "other
|
|
// calls this node" (incoming-calls direction). Existing edges get
|
|
// promoted to lsp_resolved; missing edges get added.
|
|
//
|
|
// fromRanges carries the call-expression ranges the server reported
|
|
// for this hop — per the LSP spec they always live in the CALLER's
|
|
// file, for both directions. New edges are stamped at those lines
|
|
// (one edge per distinct call-site line), so a synthesized
|
|
// interface-dispatch edge points at the `b.Name()` expression, not
|
|
// at the calling function's declaration line. When the server
|
|
// returned no ranges, the caller's declaration line remains the
|
|
// fallback anchor.
|
|
func (p *Provider) recordHierarchyCall(g graph.Store, repoPrefix, absRoot string, n *graph.Node, other CallHierarchyItem, asOutgoing bool, fromRanges []Range, result *semantic.EnrichResult) {
|
|
otherPath := uriToPath(other.URI, absRoot)
|
|
if otherPath == "" {
|
|
return
|
|
}
|
|
// A hierarchy item names a function or method — match callable
|
|
// kinds only. The generic innermost-node matcher used to land on
|
|
// a KindParam node here (params share the declaration line with
|
|
// a zero-height span, so they always won the innermost tie),
|
|
// wiring call edges to `<fn>#param:<name>` endpoints.
|
|
otherNode := semantic.MatchCallableByFileLine(g, scopedPath(repoPrefix, otherPath),
|
|
other.SelectionRange.Start.Line+1)
|
|
if otherNode == nil {
|
|
return
|
|
}
|
|
from, to := n, otherNode
|
|
if !asOutgoing {
|
|
from, to = otherNode, n
|
|
}
|
|
if from.ID == to.ID {
|
|
return
|
|
}
|
|
|
|
// One pass over the caller's out-edges: bucket every existing
|
|
// (from, to, calls) edge by line so per-line dedup and per-site
|
|
// promotion share a single fetch.
|
|
var existing *graph.Edge
|
|
pairEdges := 0
|
|
byLine := map[int][]*graph.Edge{}
|
|
for _, e := range g.GetOutEdges(from.ID) {
|
|
if e.Kind != graph.EdgeCalls || e.To != to.ID {
|
|
continue
|
|
}
|
|
byLine[e.Line] = append(byLine[e.Line], e)
|
|
pairEdges++
|
|
if existing == nil {
|
|
existing = e
|
|
}
|
|
}
|
|
promote := func(e *graph.Edge) {
|
|
if graph.OriginRank(e.Origin) < graph.OriginRank(graph.OriginLSPResolved) {
|
|
semantic.ConfirmEdge(e, p.Name())
|
|
e.Origin = graph.OriginLSPResolved
|
|
semantic.PersistEdge(g, e)
|
|
result.EdgesConfirmed++
|
|
}
|
|
}
|
|
|
|
lines := callSiteLines(fromRanges)
|
|
if len(lines) == 0 {
|
|
// No precise ranges from the server. The (from → to) pair is
|
|
// still server-verified, but WHICH line each edge sits on is
|
|
// not: promote only when the pair has exactly one candidate
|
|
// edge. With several candidate lines and no ranges, promoting
|
|
// the first was an arbitrary pick that could stamp the lsp
|
|
// tier onto a heuristically-misbound site — those stay at
|
|
// their heuristic tier instead.
|
|
if existing != nil {
|
|
if pairEdges == 1 {
|
|
promote(existing)
|
|
}
|
|
return
|
|
}
|
|
semantic.AddSemanticEdge(g, from.ID, to.ID, graph.EdgeCalls,
|
|
from.FilePath, from.StartLine, p.Name())
|
|
result.EdgesAdded++
|
|
return
|
|
}
|
|
// Server-verified call sites: promote EVERY existing pair edge at a
|
|
// verified line, mint the rest. Promoting only the first edge left a
|
|
// caller's second call site as text_matched — which the read-path
|
|
// precision filter then suppressed, silently costing recall on
|
|
// repeated calls within one function.
|
|
for _, line := range lines {
|
|
if es, ok := byLine[line]; ok {
|
|
for _, e := range es {
|
|
promote(e)
|
|
}
|
|
continue
|
|
}
|
|
semantic.AddSemanticEdge(g, from.ID, to.ID, graph.EdgeCalls,
|
|
from.FilePath, line, p.Name())
|
|
result.EdgesAdded++
|
|
}
|
|
}
|
|
|
|
// callSiteLines lowers a hop's fromRanges to the distinct, sorted
|
|
// 1-based start lines of the call expressions. Zero-valued / negative
|
|
// lines are dropped.
|
|
func callSiteLines(ranges []Range) []int {
|
|
if len(ranges) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int]bool, len(ranges))
|
|
lines := make([]int, 0, len(ranges))
|
|
for _, r := range ranges {
|
|
line := r.Start.Line + 1
|
|
if line <= 0 || seen[line] {
|
|
continue
|
|
}
|
|
seen[line] = true
|
|
lines = append(lines, line)
|
|
}
|
|
sort.Ints(lines)
|
|
return lines
|
|
}
|
|
|
|
// linkTypeHierarchy emits the right edge kind for one super/subtype
|
|
// hop. When asSupertype=true, the hop is `cur → other` (cur extends
|
|
// or implements other). When false, the hop is `other → cur`.
|
|
//
|
|
// Beyond the type-level edge, it also walks the methods of the child
|
|
// type (the `from` side) and emits EdgeOverrides for every method
|
|
// whose name matches a method on the parent — closing the
|
|
// method-level half of the type hierarchy (Joern calls these
|
|
// CONTAINS + OVERRIDES).
|
|
func (p *Provider) linkTypeHierarchy(g graph.Store, repoPrefix, absRoot string, cur *graph.Node, other TypeHierarchyItem, asSupertype bool, result *semantic.EnrichResult) {
|
|
otherPath := uriToPath(other.URI, absRoot)
|
|
if otherPath == "" {
|
|
return
|
|
}
|
|
otherNode := semantic.MatchNodeByFileLine(g, scopedPath(repoPrefix, otherPath), other.SelectionRange.Start.Line+1)
|
|
if otherNode == nil {
|
|
return
|
|
}
|
|
from, to := cur, otherNode
|
|
if !asSupertype {
|
|
from, to = otherNode, cur
|
|
}
|
|
kind := graph.EdgeExtends
|
|
if to.Kind == graph.KindInterface {
|
|
kind = graph.EdgeImplements
|
|
}
|
|
if from.ID == to.ID {
|
|
return
|
|
}
|
|
existing := semantic.FindMatchingEdge(g, from.ID, to.ID, kind)
|
|
if existing != nil {
|
|
if graph.OriginRank(existing.Origin) < graph.OriginRank(graph.OriginLSPResolved) {
|
|
semantic.ConfirmEdge(existing, p.Name())
|
|
existing.Origin = graph.OriginLSPResolved
|
|
semantic.PersistEdge(g, existing)
|
|
result.EdgesConfirmed++
|
|
}
|
|
} else {
|
|
semantic.AddSemanticEdge(g, from.ID, to.ID, kind, from.FilePath, from.StartLine, p.Name())
|
|
result.EdgesAdded++
|
|
}
|
|
|
|
// Method-level override edges: child methods that share a name
|
|
// with parent methods.
|
|
addOverrideEdges(g, from, to, p.Name(), graph.OriginLSPDispatch, result)
|
|
}
|
|
|
|
// addOverrideEdges emits EdgeOverrides from each method of child to
|
|
// the matching method of parent (matched by name). Parent methods are
|
|
// resolved via EdgeMemberOf (`m -member_of-> parent`) so the routine
|
|
// works regardless of language as long as the AST extractor recorded
|
|
// member_of for methods.
|
|
//
|
|
// origin lets the caller stamp the edges with lsp_dispatch (LSP-
|
|
// confirmed parent), ast_resolved (AST-confirmed parent in the same
|
|
// compilation unit), or ast_inferred (parent is a heuristic match).
|
|
func addOverrideEdges(g graph.Store, child, parent *graph.Node, provider, origin string, result *semantic.EnrichResult) {
|
|
if child == nil || parent == nil || child.ID == parent.ID {
|
|
return
|
|
}
|
|
parentMethods := map[string]*graph.Node{}
|
|
for _, e := range g.GetInEdges(parent.ID) {
|
|
if e.Kind != graph.EdgeMemberOf {
|
|
continue
|
|
}
|
|
m := g.GetNode(e.From)
|
|
if m == nil || m.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
parentMethods[m.Name] = m
|
|
}
|
|
if len(parentMethods) == 0 {
|
|
return
|
|
}
|
|
for _, e := range g.GetInEdges(child.ID) {
|
|
if e.Kind != graph.EdgeMemberOf {
|
|
continue
|
|
}
|
|
m := g.GetNode(e.From)
|
|
if m == nil || m.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
pm, ok := parentMethods[m.Name]
|
|
if !ok || pm.ID == m.ID {
|
|
continue
|
|
}
|
|
existing := semantic.FindMatchingEdge(g, m.ID, pm.ID, graph.EdgeOverrides)
|
|
if existing != nil {
|
|
if graph.OriginRank(existing.Origin) < graph.OriginRank(origin) {
|
|
semantic.ConfirmEdge(existing, provider)
|
|
existing.Origin = origin
|
|
semantic.PersistEdge(g, existing)
|
|
if result != nil {
|
|
result.EdgesConfirmed++
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
ed := semantic.AddSemanticEdge(g, m.ID, pm.ID, graph.EdgeOverrides, m.FilePath, m.StartLine, provider)
|
|
if ed != nil && ed.Origin != origin {
|
|
ed.Origin = origin
|
|
semantic.PersistEdge(g, ed)
|
|
}
|
|
if result != nil {
|
|
result.EdgesAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
// languageMatches returns true when n.Language is one of the
|
|
// languages this provider serves.
|
|
func (p *Provider) languageMatches(lang string) bool {
|
|
for _, l := range p.languages {
|
|
if l == lang {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// servesFile reports whether relPath's extension is one this provider's server
|
|
// can actually compile — the guard that stops enrichment from ever sending a
|
|
// language server a file outside its coverage. Without it, an ambiguous edge
|
|
// or interface whose file is an asset (.png/.svg) or an unrelated script (.sh)
|
|
// would be opened on, say, clangd, which then tries to build an AST with an
|
|
// inferred C++ compile command and logs invalid-AST churn for zero signal.
|
|
// With no ServerSpec (unit-test providers) it defaults to true so fakes that
|
|
// drive arbitrary extensions are unaffected.
|
|
func (p *Provider) servesFile(relPath string) bool {
|
|
if p.spec == nil || len(p.spec.Extensions) == 0 {
|
|
return true
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(relPath))
|
|
if ext == "" {
|
|
return false
|
|
}
|
|
for _, e := range p.spec.Extensions {
|
|
if strings.ToLower(e) == ext {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// repoScopedNodes returns the repo's nodes via the indexed GetRepoNodes scan
|
|
// rather than a whole-graph AllNodes walk — the latter is O(graph) per
|
|
// provider per repo on a disk backend. For the embedded single-repo path
|
|
// (repoPrefix == "") where GetRepoNodes can return empty on some backends,
|
|
// it falls back to AllNodes so the standalone server still enriches.
|
|
func (p *Provider) repoScopedNodes(g graph.Store, repoPrefix string) []*graph.Node {
|
|
nodes := g.GetRepoNodes(repoPrefix)
|
|
if len(nodes) == 0 && repoPrefix == "" {
|
|
return g.AllNodes()
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// repoScopedNodesLight fetches this repo's nodes via the store's optional
|
|
// LightNodeReader fast path when available — skipping the meta-blob decode
|
|
// for the majority of nodes that are already fully enriched — falling back
|
|
// to the full repoScopedNodes scan otherwise. The bool result reports
|
|
// whether the light path was taken: when true, the returned nodes must not
|
|
// be round-tripped back through AddBatch as-is (see graph.LightNodeReader);
|
|
// the caller re-fetches in full whatever subset it intends to stamp.
|
|
func (p *Provider) repoScopedNodesLight(g graph.Store, repoPrefix string) ([]*graph.Node, bool) {
|
|
if lr, ok := g.(graph.LightNodeReader); ok {
|
|
return lr.GetRepoNodesLight(repoPrefix), true
|
|
}
|
|
return p.repoScopedNodes(g, repoPrefix), false
|
|
}
|
|
|
|
// repoScopedEdges returns the edges whose source node belongs to
|
|
// repoPrefix via the indexed GetRepoEdges scan, falling back to AllEdges
|
|
// only for the embedded single-repo ("") path where GetRepoEdges returns
|
|
// nothing by contract.
|
|
func (p *Provider) repoScopedEdges(g graph.Store, repoPrefix string) []*graph.Edge {
|
|
edges := g.GetRepoEdges(repoPrefix)
|
|
if len(edges) == 0 && repoPrefix == "" {
|
|
return g.AllEdges()
|
|
}
|
|
return edges
|
|
}
|
|
|
|
// prepareCallHierarchy queries textDocument/prepareCallHierarchy and
|
|
// returns the items the server resolved at the given position. Empty
|
|
// (and nil error) means the server doesn't recognise a function-like
|
|
// symbol at that location.
|
|
func (p *Provider) prepareCallHierarchy(repoRoot, relPath string, line, col int) ([]CallHierarchyItem, error) {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := CallHierarchyPrepareParams{
|
|
TextDocumentPositionParams: TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
},
|
|
}
|
|
p.reqStats.prepareCallHierarchy.Add(1)
|
|
var items []CallHierarchyItem
|
|
if err := p.client.Call("textDocument/prepareCallHierarchy", params, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// outgoingCalls queries callHierarchy/outgoingCalls for one item.
|
|
func (p *Provider) outgoingCalls(item CallHierarchyItem) ([]CallHierarchyOutgoingCall, error) {
|
|
p.reqStats.outgoingCalls.Add(1)
|
|
var calls []CallHierarchyOutgoingCall
|
|
if err := p.client.Call("callHierarchy/outgoingCalls",
|
|
CallHierarchyOutgoingCallsParams{Item: item}, &calls); err != nil {
|
|
return nil, err
|
|
}
|
|
return calls, nil
|
|
}
|
|
|
|
// incomingCalls queries callHierarchy/incomingCalls for one item.
|
|
func (p *Provider) incomingCalls(item CallHierarchyItem) ([]CallHierarchyIncomingCall, error) {
|
|
p.reqStats.incomingCalls.Add(1)
|
|
var calls []CallHierarchyIncomingCall
|
|
if err := p.client.Call("callHierarchy/incomingCalls",
|
|
CallHierarchyIncomingCallsParams{Item: item}, &calls); err != nil {
|
|
return nil, err
|
|
}
|
|
return calls, nil
|
|
}
|
|
|
|
// prepareTypeHierarchy queries textDocument/prepareTypeHierarchy.
|
|
func (p *Provider) prepareTypeHierarchy(repoRoot, relPath string, line, col int) ([]TypeHierarchyItem, error) {
|
|
absPath := filepath.Join(repoRoot, relPath)
|
|
params := TypeHierarchyPrepareParams{
|
|
TextDocumentPositionParams: TextDocumentPositionParams{
|
|
TextDocument: TextDocumentIdentifier{URI: pathToURI(absPath)},
|
|
Position: Position{Line: line, Character: col},
|
|
},
|
|
}
|
|
p.reqStats.prepareTypeHierarchy.Add(1)
|
|
var items []TypeHierarchyItem
|
|
if err := p.client.Call("textDocument/prepareTypeHierarchy", params, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// supertypes queries typeHierarchy/supertypes.
|
|
func (p *Provider) supertypes(item TypeHierarchyItem) ([]TypeHierarchyItem, error) {
|
|
p.reqStats.supertypes.Add(1)
|
|
var items []TypeHierarchyItem
|
|
if err := p.client.Call("typeHierarchy/supertypes",
|
|
TypeHierarchySupertypesParams{Item: item}, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// subtypes queries typeHierarchy/subtypes.
|
|
func (p *Provider) subtypes(item TypeHierarchyItem) ([]TypeHierarchyItem, error) {
|
|
p.reqStats.subtypes.Add(1)
|
|
var items []TypeHierarchyItem
|
|
if err := p.client.Call("typeHierarchy/subtypes",
|
|
TypeHierarchySubtypesParams{Item: item}, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// pathToURI converts a file path to a file:// URI (Windows-correct).
|
|
func pathToURI(path string) string {
|
|
return lspuri.PathToURI(path)
|
|
}
|
|
|
|
// buildWorkspaceFolders returns the LSP workspaceFolders list — the
|
|
// primary root followed by any additional roots.
|
|
func buildWorkspaceFolders(primary string, additional []string) []WorkspaceFolder {
|
|
folders := make([]WorkspaceFolder, 0, len(additional)+1)
|
|
folders = append(folders, WorkspaceFolder{
|
|
URI: pathToURI(primary),
|
|
Name: filepath.Base(primary),
|
|
})
|
|
for _, f := range additional {
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if abs, err := filepath.Abs(f); err == nil {
|
|
f = abs
|
|
}
|
|
folders = append(folders, WorkspaceFolder{
|
|
URI: pathToURI(f),
|
|
Name: filepath.Base(f),
|
|
})
|
|
}
|
|
return folders
|
|
}
|
|
|
|
// uriToPath converts a file:// URI to a repo-relative path (Windows-correct).
|
|
func uriToPath(uri, repoRoot string) string {
|
|
return lspuri.URIToRepoRel(uri, repoRoot)
|
|
}
|
|
|
|
// lspLine converts a node's 1-based StartLine to the 0-based line LSP
|
|
// positions use, reporting ok=false for nodes without a real source
|
|
// position (StartLine < 1 — synthetic module/package nodes and extractor
|
|
// fallbacks). Sending such a node would put position.line == -1 on the
|
|
// wire, which servers reject per request (gopls: "cannot unmarshal
|
|
// number -1 into ... position.line of type uint32") — a guaranteed
|
|
// wasted round trip. Skipping beats clamping to line 0: the identifier
|
|
// is not there, so a clamped request would at best return junk for a
|
|
// different symbol.
|
|
func lspLine(n *graph.Node) (int, bool) {
|
|
if n.StartLine < 1 {
|
|
return 0, false
|
|
}
|
|
return n.StartLine - 1, true
|
|
}
|
|
|
|
// identifierColumn returns the 0-based column of the first
|
|
// occurrence of name on the given 1-based line of src. Returns 0
|
|
// when the source doesn't have the line, the name isn't found on
|
|
// it, or name is empty — col=0 was the previous unconditional
|
|
// default and remains a safe fallback for those edge cases.
|
|
//
|
|
// Why this matters: most LSP servers (gopls, jdtls, rust-analyzer,
|
|
// kotlin-ls, omnisharp, pyright) require the position cursor to be
|
|
// _on_ the identifier for textDocument/references and
|
|
// textDocument/implementation. Pinning to col=0 silently empty-resulted
|
|
// every method declaration in indented contexts (`func (f *Foo) Bar()`
|
|
// — col=0 is the `func` keyword, not `Bar`). Resolving to the actual
|
|
// identifier column unblocks the bulk of cross-file edge promotion.
|
|
func identifierColumn(src []byte, oneBasedLine int, name string) int {
|
|
col, _ := identifierColumnStrict(src, oneBasedLine, name)
|
|
return col
|
|
}
|
|
|
|
// identifierColumnStrict is identifierColumn with an explicit found
|
|
// flag: (0, false) when the source has no such line or the line does
|
|
// not contain the whole identifier. Callers that would otherwise fire
|
|
// an LSP request at a junk position (column 0 of an unrelated token)
|
|
// can skip the round trip instead.
|
|
func identifierColumnStrict(src []byte, oneBasedLine int, name string) (int, bool) {
|
|
if name == "" || oneBasedLine <= 0 || len(src) == 0 {
|
|
return 0, false
|
|
}
|
|
// Walk to the start of the requested line.
|
|
target := oneBasedLine - 1
|
|
lineStart := 0
|
|
cur := 0
|
|
for cur < len(src) && target > 0 {
|
|
if src[cur] == '\n' {
|
|
target--
|
|
lineStart = cur + 1
|
|
}
|
|
cur++
|
|
}
|
|
if target > 0 {
|
|
return 0, false
|
|
}
|
|
lineEnd := lineStart
|
|
for lineEnd < len(src) && src[lineEnd] != '\n' {
|
|
lineEnd++
|
|
}
|
|
line := string(src[lineStart:lineEnd])
|
|
idx := identifierIndex(line, name)
|
|
if idx < 0 {
|
|
return 0, false
|
|
}
|
|
return idx, true
|
|
}
|
|
|
|
// identifierIndex returns the column of the first occurrence of name in
|
|
// line that is a WHOLE identifier — not a substring of a longer one. The
|
|
// naive scan silently targeted the wrong symbol whenever a method's name
|
|
// is contained in its receiver type: for `func (formBinding) Bind(...)`
|
|
// the first "Bind" sits inside "formBinding", so hover /
|
|
// prepareCallHierarchy / implementations were asked about the TYPE
|
|
// identifier — prepare returned no items and the entire incoming-call
|
|
// fan-out never ran for that method (every implementation of a
|
|
// same-named interface method lost all its dispatch callers).
|
|
func identifierIndex(line, name string) int {
|
|
if name == "" {
|
|
return -1
|
|
}
|
|
for from := 0; from+len(name) <= len(line); {
|
|
idx := strings.Index(line[from:], name)
|
|
if idx < 0 {
|
|
return -1
|
|
}
|
|
idx += from
|
|
beforeOK := idx == 0 || !isIdentByte(line[idx-1])
|
|
afterOK := idx+len(name) == len(line) || !isIdentByte(line[idx+len(name)])
|
|
if beforeOK && afterOK {
|
|
return idx
|
|
}
|
|
from = idx + 1
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// isIdentByte reports whether c can appear inside an ASCII identifier.
|
|
// Multi-byte runes are treated as boundaries — a heuristic that errs
|
|
// toward accepting a match, which is still strictly tighter than the
|
|
// unbounded substring scan this replaces.
|
|
func isIdentByte(c byte) bool {
|
|
return c == '_' ||
|
|
(c >= 'a' && c <= 'z') ||
|
|
(c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9')
|
|
}
|
|
|
|
// extractTypeFromHover extracts type information from hover text.
|
|
func extractTypeFromHover(hover string) string {
|
|
// Remove markdown code fences.
|
|
hover = strings.TrimPrefix(hover, "```go\n")
|
|
hover = strings.TrimPrefix(hover, "```java\n")
|
|
hover = strings.TrimPrefix(hover, "```\n")
|
|
hover = strings.TrimSuffix(hover, "\n```")
|
|
hover = strings.TrimSpace(hover)
|
|
|
|
lines := strings.SplitN(hover, "\n", 2)
|
|
if len(lines) > 0 {
|
|
line := strings.TrimSpace(lines[0])
|
|
// Go keywords
|
|
if strings.HasPrefix(line, "func ") ||
|
|
strings.HasPrefix(line, "type ") ||
|
|
strings.HasPrefix(line, "var ") ||
|
|
strings.HasPrefix(line, "const ") ||
|
|
strings.HasPrefix(line, "field ") ||
|
|
strings.HasPrefix(line, "package ") {
|
|
return line
|
|
}
|
|
// Java keywords / modifiers — jdtls hover format:
|
|
// "public class Foo", "void bar()", "private String baz",
|
|
// "abstract class X", "interface Y", "@Deprecated",
|
|
// "static final int N", "enum Color", "protected Object"
|
|
if strings.HasPrefix(line, "public ") ||
|
|
strings.HasPrefix(line, "private ") ||
|
|
strings.HasPrefix(line, "protected ") ||
|
|
strings.HasPrefix(line, "abstract ") ||
|
|
strings.HasPrefix(line, "static ") ||
|
|
strings.HasPrefix(line, "final ") ||
|
|
strings.HasPrefix(line, "class ") ||
|
|
strings.HasPrefix(line, "interface ") ||
|
|
strings.HasPrefix(line, "enum ") ||
|
|
strings.HasPrefix(line, "void ") ||
|
|
strings.HasPrefix(line, "@") {
|
|
return line
|
|
}
|
|
// Short type like "string", "*Foo", "[]byte", "int", "boolean".
|
|
if !strings.Contains(line, " ") && len(line) > 0 && len(line) < 100 {
|
|
return line
|
|
}
|
|
}
|
|
return ""
|
|
}
|