Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

239 lines
8.5 KiB
Go

package languages
import (
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// Function-as-value capture.
//
// When a function is passed as a *value* rather than called — registering a
// handler (`router.Get("/x", handler)`), a callback (`list.forEach(process)`),
// an observer (`signal.connect(onChange)`), `&fn` / `Class::method` /
// `method(:sym)` special forms — no direct call edge exists, yet the function
// is genuinely reachable through the registration. A per-language AST walk
// collects each such value-position identifier as a FnValueCandidate; this file
// is the shared capture table and placeholder emitter. The resolver's
// ResolveFnValueCallbacks gate then binds each candidate to a real same-file
// function and drops the unbound ones.
//
// Splitting capture (here) from the gate (resolver) keeps the two halves
// independently testable and lets every language reuse one emitter instead of
// hand-rolling placeholder edges. The per-language value-position walks land on
// top of this skeleton.
// fnValueUnresolvedPrefix is the synthetic-target namespace a captured
// function value occupies until the gate binds it — the graph-owned
// graph.FnValuePlaceholderMarker, aliased here so the emit sites read locally
// and the resolver's pending-scan exclusion keys off the same constant. Kept
// human-readable (the bare name is appended) to match Gortex's navigable-ID
// convention.
const fnValueUnresolvedPrefix = graph.FnValuePlaceholderMarker
// fnValueCandidateVia marks a placeholder edge as awaiting the callback gate.
// It mirrors the resolver-side constant of the same value; the two packages
// share the string convention, not a symbol, to avoid an import cycle.
const fnValueCandidateVia = "callback_candidate"
// FnValueCandidate is one captured function-as-value reference: the identifier
// used in a value position (Name), the enclosing symbol or registration site it
// was found in (FromID), and its source location. A per-language walk
// accumulates these during extraction and flushes them with
// EmitFnValueCandidates.
type FnValueCandidate struct {
FromID string
Name string
FilePath string
Line int
// Form is the wrapper form the value sat in: "" (plain), "address_of"
// (`&fn` / `@fn`), or "eta" (Scala `f _`).
Form string
// Lang is the capturing grammar; Ungated marks a qualified-path candidate
// the gate may resolve cross-module.
Lang string
Ungated bool
// RecvHint scopes the gate's resolution for a special form: "<self>" (the
// enclosing type), a concrete type name, or "" (repo-wide).
RecvHint string
// SkipGate marks a curated-HOF string callable that bypasses the same-file
// gate and resolves by a repo-wide unique-or-drop rule.
SkipGate bool
}
// captureFnValueCandidates records a function-as-value candidate for every
// identifier in the parse tree that names a same-file function/method yet is
// NOT in callee position — i.e. a function passed by bare name as a call
// argument, assigned to a field/variable, or placed in an initializer, rather
// than invoked. The resolver's ResolveFnValueCallbacks then binds each to its
// definition as a tiered callback-registration edge, recovering a real
// dependency that static call extraction misses (callers(handler) was empty).
//
// It is grammar-agnostic in two ways: it keys on `identifier` leaf nodes, and
// it distinguishes a call from a value by a source-level check — an identifier
// immediately followed (past whitespace) by '(' is the callee of a call (an
// existing call edge), so only the non-called uses become candidates. That also
// excludes a function's own `name(params)` declaration. Pre-filtering to names
// the file actually declares means it never emits a candidate that cannot bind.
// Call it once at the end of an extractor's Extract with the parse-tree root.
func captureFnValueCandidates(result *parser.ExtractionResult, root *sitter.Node, filePath string, src []byte) {
if root == nil || result == nil {
return
}
lang := ""
funcs := map[string]bool{}
for _, n := range result.Nodes {
if n == nil || n.FilePath != filePath {
continue
}
if n.Kind == graph.KindFile && lang == "" {
lang = n.Language
}
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
funcs[n.Name] = true
}
}
funcRanges := buildFuncRanges(result)
if len(funcRanges) == 0 {
return
}
spec := fnRefSpecFor(lang)
var cands []FnValueCandidate
seen := map[string]bool{}
walkNodes(root, func(n *sitter.Node) {
// Special whole-node forms (method references, this.m / self.m,
// Ruby symbol callables, selectors) are normalised to a (member,
// receiver-hint) pair before the plain-identifier path.
if refName, recvHint, ok := normalizeFnRefSpecial(n, src); ok {
typeQualified := recvHint != "" && recvHint != "<self>"
// Flood guard: a `<self>` / selector member must name a same-file
// function/method; a type-qualified ref (`Foo::bar`) may bind
// cross-file via the gate.
if !typeQualified && !funcs[refName] {
return
}
if fnRefStartsCall(spec, n, src) {
return
}
line := int(n.StartPoint().Row) + 1
fromID := findEnclosingFunc(funcRanges, line)
if fromID == "" {
return
}
key := fromID + "\x00special\x00" + refName + "\x00" + recvHint
if seen[key] {
return
}
seen[key] = true
cands = append(cands, FnValueCandidate{
FromID: fromID, Name: refName, FilePath: filePath, Line: line,
Form: "special", RecvHint: recvHint, Lang: lang, Ungated: typeQualified,
})
return
}
if !spec.matchesIDNode(n.Type()) {
return
}
// Skip an identifier that is a component of a special form already
// captured as a whole (the `valueOf` in `Demo::valueOf`, the `handle`
// in `self.handle`), so it is not double-counted as a bare name.
if p := n.Parent(); p != nil {
if _, _, ok := normalizeFnRefSpecial(p, src); ok {
return
}
}
name := fnRefNodeName(n, src)
if name == "" {
return
}
ungated := false
if !funcs[name] {
// A name the file does not declare can only bind cross-module, and
// only when it is an explicit qualified path (e.g. Rust `m::f`) —
// a bare identifier that is not a same-file function is a local /
// param / builtin and is dropped to avoid flooding the gate.
if !spec.ungated || !isQualifiedFnRefNode(n.Type()) {
return
}
ungated = true
}
if fnRefStartsCall(spec, n, src) {
return // callee of a call (incl. tagged template), not a value use
}
line := int(n.StartPoint().Row) + 1
fromID := findEnclosingFunc(funcRanges, line)
if fromID == "" {
return
}
key := fromID + "\x00" + name
if seen[key] {
return
}
seen[key] = true
cands = append(cands, FnValueCandidate{
FromID: fromID, Name: name, FilePath: filePath, Line: line,
Form: spec.fnRefForm(n), Lang: lang, Ungated: ungated,
})
})
EmitFnValueCandidates(result, cands)
}
// byteAfterIdentStartsCall reports whether the first non-whitespace byte at or
// after i begins a call of the preceding identifier — '(' for an ordinary call
// or '`' for a tagged-template call (`tag`...“). Either means the identifier is
// a callee, not a function-as-value reference.
func byteAfterIdentStartsCall(src []byte, i int) bool {
for i < len(src) {
switch src[i] {
case ' ', '\t', '\r', '\n':
i++
case '(', '`':
return true
default:
return false
}
}
return false
}
// EmitFnValueCandidates appends one placeholder reference edge per candidate to
// result. Each edge targets the fn-value namespace and carries the captured
// name in Meta so the resolver gate can bind it; the edge rides
// OriginSpeculative until then. Candidates missing a source site or name are
// skipped.
func EmitFnValueCandidates(result *parser.ExtractionResult, cands []FnValueCandidate) {
for _, c := range cands {
if c.FromID == "" || c.Name == "" {
continue
}
meta := map[string]any{
"via": fnValueCandidateVia,
"fn_value_name": c.Name,
}
if c.Form != "" {
meta["fn_ref_form"] = c.Form
}
if c.Lang != "" {
meta["fn_ref_lang"] = c.Lang
}
if c.Ungated {
meta["fn_value_ungated"] = true
}
if c.RecvHint != "" {
meta["fn_ref_recv_hint"] = c.RecvHint
}
if c.SkipGate {
meta["skip_gate"] = true
}
result.Edges = append(result.Edges, &graph.Edge{
From: c.FromID,
To: fnValueUnresolvedPrefix + c.Name,
Kind: graph.EdgeReferences,
FilePath: c.FilePath,
Line: c.Line,
Origin: graph.OriginSpeculative,
Meta: meta,
})
}
}