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
239 lines
8.5 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|