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
474 lines
13 KiB
Go
474 lines
13 KiB
Go
package languages
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
|
)
|
|
|
|
// emitPyFunctionShape emits KindParam / EdgeParamOf / EdgeTypedAs /
|
|
// EdgeReturns / KindGenericParam for a Python function_definition.
|
|
//
|
|
// ownerID is the qualified ID under which the params/returns/generics
|
|
// should be attributed. Skipping `self` and `cls` keeps method
|
|
// metadata consistent with how the rest of the graph treats receivers
|
|
// (KindMethod nodes already carry meta.receiver). The blank
|
|
// identifier `_` is also skipped.
|
|
func emitPyFunctionShape(ownerID string, funcNode *sitter.Node, src []byte, filePath string, declLine int, result *parser.ExtractionResult) {
|
|
if funcNode == nil {
|
|
return
|
|
}
|
|
if params := funcNode.ChildByFieldName("parameters"); params != nil {
|
|
emitPyParamNodes(ownerID, params, src, filePath, declLine, result)
|
|
}
|
|
if rt := pyReturnTypeRaw(funcNode, src); rt != "" {
|
|
emitPyReturnEdges(ownerID, rt, filePath, declLine, result)
|
|
}
|
|
emitPyGenericParamNodes(ownerID, funcNode, src, filePath, declLine, result)
|
|
if body := funcNode.ChildByFieldName("body"); body != nil {
|
|
emitPyAsyncSpawns(ownerID, body, src, filePath, result)
|
|
}
|
|
}
|
|
|
|
// emitPyAsyncSpawns walks a Python function body for await
|
|
// expressions and asyncio.{gather, create_task, ensure_future, run}
|
|
// calls, emitting EdgeSpawns from the enclosing function to the
|
|
// awaited target.
|
|
func emitPyAsyncSpawns(ownerID string, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
|
|
if body == nil {
|
|
return
|
|
}
|
|
seen := map[string]bool{}
|
|
emit := func(target, mode string, line int) {
|
|
if target == "" {
|
|
return
|
|
}
|
|
key := mode + "\x00" + target
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: ownerID,
|
|
To: "unresolved::" + target,
|
|
Kind: graph.EdgeSpawns,
|
|
FilePath: filePath,
|
|
Line: line,
|
|
Origin: graph.OriginASTInferred,
|
|
Meta: map[string]any{
|
|
"mode": mode,
|
|
},
|
|
})
|
|
}
|
|
walkPyNodes(body, func(n *sitter.Node) bool {
|
|
switch n.Type() {
|
|
case "function_definition", "lambda":
|
|
// Don't descend into nested function bodies.
|
|
return false
|
|
case "await":
|
|
// `await foo()` parses as (await (call …)). Walk for
|
|
// the call node directly.
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
c := n.NamedChild(i)
|
|
if c == nil || c.Type() != "call" {
|
|
continue
|
|
}
|
|
if name := pyCallTargetName(c, src); name != "" {
|
|
emit(name, "async", int(n.StartPoint().Row)+1)
|
|
}
|
|
}
|
|
case "call":
|
|
fn := n.ChildByFieldName("function")
|
|
if fn == nil {
|
|
return true
|
|
}
|
|
if fn.Type() == "attribute" {
|
|
obj := fn.ChildByFieldName("object")
|
|
attr := fn.ChildByFieldName("attribute")
|
|
if obj != nil && attr != nil && obj.Content(src) == "asyncio" {
|
|
attrName := attr.Content(src)
|
|
switch attrName {
|
|
case "gather", "create_task", "ensure_future", "run", "wait", "wait_for", "shield":
|
|
emit("asyncio."+attrName, "async", int(n.StartPoint().Row)+1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// walkPyNodes is a Python-grammar pre-order walker that mirrors the
|
|
// Go and TS variants. Returning false from visit skips the subtree.
|
|
func walkPyNodes(n *sitter.Node, visit func(*sitter.Node) bool) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
if !visit(n) {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walkPyNodes(n.NamedChild(i), visit)
|
|
}
|
|
}
|
|
|
|
// pyCallTargetName extracts the textual function name of a Python
|
|
// call node. attribute (`a.b()`) returns the attribute name; bare
|
|
// identifier returns its text. Higher-order or complex callees return
|
|
// "".
|
|
func pyCallTargetName(call *sitter.Node, src []byte) string {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil {
|
|
return ""
|
|
}
|
|
switch fn.Type() {
|
|
case "identifier":
|
|
return fn.Content(src)
|
|
case "attribute":
|
|
if a := fn.ChildByFieldName("attribute"); a != nil {
|
|
return a.Content(src)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func emitPyParamNodes(ownerID string, params *sitter.Node, src []byte, filePath string, declLine int, result *parser.ExtractionResult) {
|
|
pos := 0
|
|
for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ {
|
|
decl := params.NamedChild(i)
|
|
if decl == nil {
|
|
continue
|
|
}
|
|
name, typeName, variadic := pyParamShape(decl, src)
|
|
if name == "" || name == "_" || name == "self" || name == "cls" {
|
|
continue
|
|
}
|
|
paramID := ownerID + "#param:" + name + "@" + strconv.Itoa(pos)
|
|
meta := map[string]any{"position": pos}
|
|
if variadic {
|
|
meta["variadic"] = true
|
|
}
|
|
if typeName != "" {
|
|
meta["type"] = typeName
|
|
}
|
|
startLine := int(decl.StartPoint().Row) + 1
|
|
if startLine == 0 {
|
|
startLine = declLine
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: paramID,
|
|
Kind: graph.KindParam,
|
|
Name: name,
|
|
FilePath: filePath,
|
|
StartLine: startLine,
|
|
EndLine: int(decl.EndPoint().Row) + 1,
|
|
Language: "python",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: paramID,
|
|
To: ownerID,
|
|
Kind: graph.EdgeParamOf,
|
|
FilePath: filePath,
|
|
Line: startLine,
|
|
Origin: graph.OriginASTResolved,
|
|
})
|
|
if canon := canonicalizePyTypeRef(typeName); canon != "" && !isPyPrimitive(canon) {
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: paramID,
|
|
To: "unresolved::" + canon,
|
|
Kind: graph.EdgeTypedAs,
|
|
FilePath: filePath,
|
|
Line: startLine,
|
|
Origin: graph.OriginASTInferred,
|
|
})
|
|
}
|
|
pos++
|
|
}
|
|
}
|
|
|
|
// pyParamShape pulls (name, type, isVariadic) out of one parameter
|
|
// node, navigating the four shapes the Python grammar uses.
|
|
func pyParamShape(decl *sitter.Node, src []byte) (string, string, bool) {
|
|
switch decl.Type() {
|
|
case "identifier":
|
|
return decl.Content(src), "", false
|
|
case "typed_parameter":
|
|
// (typed_parameter name: (identifier) type: (type))
|
|
var name, typ string
|
|
variadic := false
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
switch c.Type() {
|
|
case "identifier":
|
|
if name == "" {
|
|
name = c.Content(src)
|
|
}
|
|
case "type":
|
|
typ = strings.TrimSpace(c.Content(src))
|
|
case "list_splat_pattern":
|
|
variadic = true
|
|
for j, _nc := 0, int(c.NamedChildCount()); j < _nc; j++ {
|
|
cc := c.NamedChild(j)
|
|
if cc != nil && cc.Type() == "identifier" {
|
|
name = cc.Content(src)
|
|
}
|
|
}
|
|
case "dictionary_splat_pattern":
|
|
variadic = true
|
|
for j, _nc := 0, int(c.NamedChildCount()); j < _nc; j++ {
|
|
cc := c.NamedChild(j)
|
|
if cc != nil && cc.Type() == "identifier" {
|
|
name = cc.Content(src)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return name, typ, variadic
|
|
case "default_parameter":
|
|
// (default_parameter name: (identifier) value: (...))
|
|
if n := decl.ChildByFieldName("name"); n != nil && n.Type() == "identifier" {
|
|
return n.Content(src), "", false
|
|
}
|
|
case "typed_default_parameter":
|
|
// (typed_default_parameter name: (identifier) type: (type) value: (...))
|
|
var name, typ string
|
|
if n := decl.ChildByFieldName("name"); n != nil && n.Type() == "identifier" {
|
|
name = n.Content(src)
|
|
}
|
|
if t := decl.ChildByFieldName("type"); t != nil {
|
|
typ = strings.TrimSpace(t.Content(src))
|
|
}
|
|
return name, typ, false
|
|
case "list_splat_pattern":
|
|
// *args bare (no annotation)
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c != nil && c.Type() == "identifier" {
|
|
return c.Content(src), "", true
|
|
}
|
|
}
|
|
case "dictionary_splat_pattern":
|
|
// **kwargs bare
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c != nil && c.Type() == "identifier" {
|
|
return c.Content(src), "", true
|
|
}
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func pyReturnTypeRaw(funcNode *sitter.Node, src []byte) string {
|
|
for i, _nc := 0, int(funcNode.NamedChildCount()); i < _nc; i++ {
|
|
c := funcNode.NamedChild(i)
|
|
if c != nil && c.Type() == "type" {
|
|
return strings.TrimSpace(c.Content(src))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func emitPyReturnEdges(ownerID, returnText, filePath string, line int, result *parser.ExtractionResult) {
|
|
if returnText == "" {
|
|
return
|
|
}
|
|
for i, raw := range splitPyUnionType(returnText) {
|
|
t := canonicalizePyTypeRef(raw)
|
|
if t == "" || isPyPrimitive(t) {
|
|
continue
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: ownerID,
|
|
To: "unresolved::" + t,
|
|
Kind: graph.EdgeReturns,
|
|
FilePath: filePath,
|
|
Line: line,
|
|
Origin: graph.OriginASTInferred,
|
|
Meta: map[string]any{
|
|
"position": i,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// emitPyTypeUseEdges parses a variable annotation type and emits one
|
|
// EdgeTypedAs per top-level named type to unresolved::<type>, so a type
|
|
// used only in `x: T` annotation position is a first-class cross-file
|
|
// reference the name-based resolver can land without an LSP. Mirrors
|
|
// emitPyReturnEdges; primitives are skipped.
|
|
func emitPyTypeUseEdges(ownerID, typeText, filePath string, line int, result *parser.ExtractionResult) {
|
|
if typeText == "" {
|
|
return
|
|
}
|
|
for _, raw := range splitPyUnionType(typeText) {
|
|
t := canonicalizePyTypeRef(raw)
|
|
if t == "" || isPyPrimitive(t) {
|
|
continue
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: ownerID,
|
|
To: "unresolved::" + t,
|
|
Kind: graph.EdgeTypedAs,
|
|
FilePath: filePath,
|
|
Line: line,
|
|
Origin: graph.OriginASTInferred,
|
|
})
|
|
}
|
|
}
|
|
|
|
func emitPyGenericParamNodes(ownerID string, funcNode *sitter.Node, src []byte, filePath string, line int, result *parser.ExtractionResult) {
|
|
tparams := funcNode.ChildByFieldName("type_parameters")
|
|
if tparams == nil {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(tparams.NamedChildCount()); i < _nc; i++ {
|
|
tp := tparams.NamedChild(i)
|
|
if tp == nil || tp.Type() != "type_parameter" {
|
|
continue
|
|
}
|
|
var name, bound string
|
|
if n := tp.ChildByFieldName("name"); n != nil {
|
|
name = n.Content(src)
|
|
}
|
|
if name == "" {
|
|
// Fallback: first identifier child.
|
|
for j, _nc := 0, int(tp.NamedChildCount()); j < _nc; j++ {
|
|
c := tp.NamedChild(j)
|
|
if c != nil && c.Type() == "identifier" {
|
|
name = c.Content(src)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if b := tp.ChildByFieldName("bound"); b != nil {
|
|
bound = strings.TrimSpace(b.Content(src))
|
|
}
|
|
gpID := ownerID + "#tparam:" + name
|
|
meta := map[string]any{}
|
|
if bound != "" {
|
|
meta["bound"] = bound
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: gpID,
|
|
Kind: graph.KindGenericParam,
|
|
Name: name,
|
|
FilePath: filePath,
|
|
StartLine: line,
|
|
EndLine: line,
|
|
Language: "python",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: gpID,
|
|
To: ownerID,
|
|
Kind: graph.EdgeMemberOf,
|
|
FilePath: filePath,
|
|
Line: line,
|
|
Origin: graph.OriginASTResolved,
|
|
})
|
|
}
|
|
}
|
|
|
|
// canonicalizePyTypeRef strips wrappers Python idiomatic typing has
|
|
// (Optional[X] / List[X] / Tuple[X, ...] / Sequence[X]) so the
|
|
// resolver can land the type on the actual class node.
|
|
func canonicalizePyTypeRef(t string) string {
|
|
t = strings.TrimSpace(t)
|
|
if t == "" {
|
|
return ""
|
|
}
|
|
// Strip "X | None" → X (PEP-604 union with None).
|
|
if parts := splitPyUnionType(t); len(parts) > 1 {
|
|
// If only one non-None branch, pick it.
|
|
nonNone := []string{}
|
|
for _, p := range parts {
|
|
if p != "None" && p != "" {
|
|
nonNone = append(nonNone, p)
|
|
}
|
|
}
|
|
if len(nonNone) == 1 {
|
|
t = nonNone[0]
|
|
}
|
|
}
|
|
for _, wrapper := range []string{"Optional", "List", "list", "Sequence", "Iterable", "Iterator", "Awaitable", "Coroutine", "Tuple", "tuple", "Set", "set", "FrozenSet"} {
|
|
prefix := wrapper + "["
|
|
if strings.HasPrefix(t, prefix) && strings.HasSuffix(t, "]") {
|
|
inner := t[len(prefix) : len(t)-1]
|
|
// Tuple[X, Y, ...] — pick first element only when the
|
|
// wrapper is a homogeneous container; for Tuple keep it
|
|
// raw when comma is present so the resolver doesn't
|
|
// accidentally land us on the wrong type.
|
|
if wrapper == "Tuple" || wrapper == "tuple" {
|
|
if idx := strings.Index(inner, ","); idx > 0 {
|
|
return canonicalizePyTypeRef(strings.TrimSpace(inner[:idx]))
|
|
}
|
|
}
|
|
return canonicalizePyTypeRef(inner)
|
|
}
|
|
}
|
|
// Generic[T1, T2] — take the head.
|
|
if idx := strings.Index(t, "["); idx > 0 {
|
|
t = t[:idx]
|
|
}
|
|
// strip surrounding parens
|
|
for strings.HasPrefix(t, "(") && strings.HasSuffix(t, ")") {
|
|
t = strings.TrimSpace(t[1 : len(t)-1])
|
|
}
|
|
// Strip module-qualified prefix: "pkg.mod.Foo" → "Foo".
|
|
if idx := strings.LastIndex(t, "."); idx >= 0 {
|
|
t = t[idx+1:]
|
|
}
|
|
return strings.TrimSpace(t)
|
|
}
|
|
|
|
func splitPyUnionType(t string) []string {
|
|
t = strings.TrimSpace(t)
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
depth := 0
|
|
parts := []string{}
|
|
cur := strings.Builder{}
|
|
for i := 0; i < len(t); i++ {
|
|
c := t[i]
|
|
switch c {
|
|
case '[', '(', '{':
|
|
depth++
|
|
case ']', ')', '}':
|
|
depth--
|
|
case '|':
|
|
if depth == 0 {
|
|
parts = append(parts, strings.TrimSpace(cur.String()))
|
|
cur.Reset()
|
|
continue
|
|
}
|
|
}
|
|
cur.WriteByte(c)
|
|
}
|
|
if cur.Len() > 0 {
|
|
parts = append(parts, strings.TrimSpace(cur.String()))
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func isPyPrimitive(t string) bool {
|
|
switch t {
|
|
case "", "None", "Any", "Never", "object",
|
|
"str", "int", "float", "bool", "bytes", "bytearray",
|
|
"complex":
|
|
return true
|
|
}
|
|
return false
|
|
}
|