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
1170 lines
38 KiB
Go
1170 lines
38 KiB
Go
package languages
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/zzet/gortex/internal/graph"
|
||
"github.com/zzet/gortex/internal/parser"
|
||
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
||
"github.com/zzet/gortex/internal/parser/tsitter/python"
|
||
)
|
||
|
||
// qPyAll is a single tree-sitter query alternating over every pattern
|
||
// the Python extractor needs. One tree walk per file replaces the 10
|
||
// `parser.RunQuery` calls the previous design made (each of which
|
||
// recompiled its query and ran an independent cursor over the whole
|
||
// tree). Capture names are disjoint across patterns so the dispatch in
|
||
// Extract can branch on which name is set. Class membership is
|
||
// resolved via a parent walk on the captured node — the function must
|
||
// be a direct child of `block` whose direct parent is
|
||
// `class_definition`, with an optional `decorated_definition` wrapper
|
||
// between the def and the block (decorated methods are methods too —
|
||
// see pyDirectClassParent).
|
||
const qPyAll = `
|
||
[
|
||
(function_definition
|
||
name: (identifier) @func.name) @func.def
|
||
|
||
(class_definition
|
||
name: (identifier) @class.name) @class.def
|
||
|
||
(import_statement
|
||
name: (dotted_name) @import.name) @import.def
|
||
|
||
(import_from_statement
|
||
module_name: (dotted_name) @import.module) @importfrom.def
|
||
|
||
(import_from_statement
|
||
module_name: (relative_import) @importfrom.rel) @importfrom_rel.def
|
||
|
||
(call
|
||
function: (identifier) @call.name) @call.expr
|
||
|
||
(call
|
||
function: (attribute
|
||
object: (_) @callattr.receiver
|
||
attribute: (identifier) @callattr.method)) @callattr.expr
|
||
|
||
(call
|
||
function: (subscript
|
||
value: (_) @subcall.receiver
|
||
subscript: (_) @subcall.key)) @subcall.expr
|
||
|
||
(assignment
|
||
left: (identifier) @var.name) @var.def
|
||
|
||
(assignment
|
||
left: (identifier) @tvar.name
|
||
type: (type (identifier) @tvar.type)) @tvar.def
|
||
|
||
(assignment
|
||
left: (identifier) @uvar.name
|
||
right: (call
|
||
function: (identifier) @uvar.callee)) @uvar.def
|
||
]
|
||
`
|
||
|
||
// PythonExtractor extracts Python source files into graph nodes and edges.
|
||
type PythonExtractor struct {
|
||
lang *sitter.Language
|
||
qAll *parser.PreparedQuery
|
||
}
|
||
|
||
func NewPythonExtractor() *PythonExtractor {
|
||
lang := python.GetLanguage()
|
||
return &PythonExtractor{
|
||
lang: lang,
|
||
qAll: parser.MustPreparedQuery(qPyAll, lang),
|
||
}
|
||
}
|
||
|
||
func (e *PythonExtractor) Language() string { return "python" }
|
||
func (e *PythonExtractor) Extensions() []string { return []string{".py"} }
|
||
|
||
// --- Deferred match buffers ----------------------------------------
|
||
|
||
type pyDeferredCall struct {
|
||
name string
|
||
receiver string // attribute receiver text
|
||
line int
|
||
isAttr bool
|
||
expr *sitter.Node // for FastAPI Depends() arg lookup
|
||
// dynShape / dynKey tag a dynamic-dispatch blind-spot call shape the
|
||
// resolver cannot bind statically (computed-member obj["foo"]()), so the
|
||
// opt-in speculative synthesizer can fan it to plausible targets. dynKey is
|
||
// the literal method name when the subscript is a string literal.
|
||
dynShape string
|
||
dynKey string
|
||
// returnUsage is how the call site consumes the return value
|
||
// (graph.ReturnUsage* label), classified at capture time and
|
||
// stamped as edge Meta on every EdgeCalls emitted for this site.
|
||
returnUsage string
|
||
}
|
||
|
||
// pyStringLiteralValue returns the unquoted value of a Python string-literal
|
||
// token (handling an optional r/f/b/u prefix), or "" when the token is not a
|
||
// string literal (e.g. a variable subscript key obj[name]).
|
||
func pyStringLiteralValue(tok string) string {
|
||
tok = strings.TrimSpace(tok)
|
||
for len(tok) > 1 {
|
||
switch tok[0] {
|
||
case 'r', 'R', 'f', 'F', 'b', 'B', 'u', 'U':
|
||
if tok[1] == '\'' || tok[1] == '"' {
|
||
tok = tok[1:]
|
||
continue
|
||
}
|
||
}
|
||
break
|
||
}
|
||
if len(tok) >= 2 {
|
||
q := tok[0]
|
||
if (q == '\'' || q == '"') && tok[len(tok)-1] == q {
|
||
return tok[1 : len(tok)-1]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (e *PythonExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
||
tree, err := parser.ParseFile(src, e.lang)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer tree.Close()
|
||
|
||
root := tree.RootNode()
|
||
result := &parser.ExtractionResult{}
|
||
|
||
fileNode := &graph.Node{
|
||
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
||
FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
|
||
Language: "python",
|
||
}
|
||
fileID := fileNode.ID
|
||
result.Nodes = append(result.Nodes, fileNode)
|
||
|
||
seen := make(map[string]bool)
|
||
annotationSeen := make(map[string]bool)
|
||
imports := map[string]string{} // alias → module path
|
||
tenv := make(typeEnv)
|
||
tenvHasExplicit := make(map[string]bool) // names with Tier 0 type, lock from Tier 1 overwrite
|
||
|
||
var calls []pyDeferredCall
|
||
var typeUses []deferredTypeUse
|
||
|
||
parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) {
|
||
switch {
|
||
|
||
case m.Captures["func.def"] != nil:
|
||
e.emitFunction(m, filePath, fileID, src, result, seen, annotationSeen)
|
||
|
||
case m.Captures["class.def"] != nil:
|
||
e.emitClass(m, filePath, fileID, src, result, seen, annotationSeen)
|
||
|
||
case m.Captures["import.def"] != nil:
|
||
e.emitImport(m, filePath, fileID, src, result, imports)
|
||
|
||
case m.Captures["importfrom.def"] != nil:
|
||
e.emitImportFrom(m, filePath, fileID, src, result, imports)
|
||
|
||
case m.Captures["importfrom_rel.def"] != nil:
|
||
e.emitImportFromRelative(m, filePath, fileID, src, result, imports)
|
||
|
||
case m.Captures["callattr.expr"] != nil:
|
||
expr := m.Captures["callattr.expr"]
|
||
calls = append(calls, pyDeferredCall{
|
||
name: m.Captures["callattr.method"].Text,
|
||
receiver: m.Captures["callattr.receiver"].Text,
|
||
line: expr.StartLine + 1,
|
||
isAttr: true,
|
||
expr: expr.Node,
|
||
returnUsage: classifyReturnUsage(expr.Node, src, pyReturnUsageSpec),
|
||
})
|
||
|
||
case m.Captures["call.expr"] != nil:
|
||
expr := m.Captures["call.expr"]
|
||
calls = append(calls, pyDeferredCall{
|
||
name: m.Captures["call.name"].Text,
|
||
line: expr.StartLine + 1,
|
||
expr: expr.Node,
|
||
returnUsage: classifyReturnUsage(expr.Node, src, pyReturnUsageSpec),
|
||
})
|
||
|
||
case m.Captures["subcall.expr"] != nil:
|
||
// Computed-member call `obj["foo"]()` — a dynamic-dispatch blind
|
||
// spot. Captured for the opt-in speculative synthesizer; emits no
|
||
// edge by itself unless that pass is enabled.
|
||
expr := m.Captures["subcall.expr"]
|
||
dc := pyDeferredCall{
|
||
line: expr.StartLine + 1,
|
||
expr: expr.Node,
|
||
dynShape: "computed_member",
|
||
returnUsage: classifyReturnUsage(expr.Node, src, pyReturnUsageSpec),
|
||
}
|
||
if r := m.Captures["subcall.receiver"]; r != nil {
|
||
dc.receiver = r.Text
|
||
}
|
||
if k := m.Captures["subcall.key"]; k != nil {
|
||
dc.dynKey = pyStringLiteralValue(k.Text)
|
||
}
|
||
calls = append(calls, dc)
|
||
|
||
case m.Captures["tvar.def"] != nil:
|
||
// Tier 0: explicit type annotation — overwrite tenv.
|
||
name := m.Captures["tvar.name"].Text
|
||
rawType := m.Captures["tvar.type"].Text
|
||
typeName := normalizePyTypeName(rawType)
|
||
if typeName != "" {
|
||
tenv[name] = typeName
|
||
tenvHasExplicit[name] = true
|
||
}
|
||
typeUses = append(typeUses, deferredTypeUse{
|
||
typeText: rawType,
|
||
line: m.Captures["tvar.def"].StartLine + 1,
|
||
})
|
||
|
||
case m.Captures["uvar.def"] != nil:
|
||
// Tier 1: constructor-call inference. Only fills in keys
|
||
// that didn't get an explicit type — match the legacy
|
||
// `if _, exists := tenv[name]; exists { continue }` guard.
|
||
name := m.Captures["uvar.name"].Text
|
||
if tenvHasExplicit[name] {
|
||
return
|
||
}
|
||
if _, exists := tenv[name]; exists {
|
||
return
|
||
}
|
||
callee := m.Captures["uvar.callee"].Text
|
||
if callee != "" && unicode.IsUpper(rune(callee[0])) {
|
||
tenv[name] = callee
|
||
}
|
||
|
||
case m.Captures["var.def"] != nil:
|
||
e.emitTopLevelVar(m, filePath, fileID, result, seen)
|
||
}
|
||
})
|
||
|
||
// All function/method nodes have been emitted; map call sites to
|
||
// their enclosing definition.
|
||
funcRanges := buildFuncRanges(result)
|
||
|
||
// Type-use edges: a `x: T` annotation references type T. Attributed
|
||
// to the enclosing function (fallback: the file node) so find_usages(T)
|
||
// surfaces every annotation site without an LSP.
|
||
for _, tu := range typeUses {
|
||
ownerID := findEnclosingFunc(funcRanges, tu.line)
|
||
if ownerID == "" {
|
||
ownerID = fileID
|
||
}
|
||
emitPyTypeUseEdges(ownerID, tu.typeText, filePath, tu.line, result)
|
||
}
|
||
|
||
// Reference forms the call/annotation passes don't cover:
|
||
// instantiation, inheritance, isinstance/issubclass, static/class
|
||
// access, and decorator references to Capitalized type names.
|
||
emitPythonReferenceForms(root, src, filePath, fileID, funcRanges, result)
|
||
|
||
for _, c := range calls {
|
||
callerID := findEnclosingFunc(funcRanges, c.line)
|
||
if callerID == "" {
|
||
continue
|
||
}
|
||
if c.dynShape != "" {
|
||
// Tagged dynamic-dispatch blind-spot call. The placeholder carries
|
||
// the shape + literal key; the speculative synthesizer (opt-in)
|
||
// fans it to plausible targets.
|
||
meta := map[string]any{"dyn_shape": c.dynShape}
|
||
if c.dynKey != "" {
|
||
meta["dyn_key"] = c.dynKey
|
||
}
|
||
if c.receiver != "" {
|
||
meta["dyn_receiver"] = c.receiver
|
||
}
|
||
edge := &graph.Edge{
|
||
From: callerID, To: "unresolved::*",
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, Meta: meta,
|
||
}
|
||
stampReturnUsage(edge, c.returnUsage)
|
||
result.Edges = append(result.Edges, edge)
|
||
continue
|
||
}
|
||
if c.isAttr {
|
||
// Module-qualified call (requests.get, np.array, os.path.join):
|
||
// attach the import path so resolver can classify externally.
|
||
if importPath, ok := lookupPyImport(c.receiver, imports); ok {
|
||
edge := &graph.Edge{
|
||
From: callerID, To: "unresolved::extern::" + importPath + "::" + c.name,
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||
}
|
||
stampReturnUsage(edge, c.returnUsage)
|
||
result.Edges = append(result.Edges, edge)
|
||
continue
|
||
}
|
||
|
||
edge := &graph.Edge{
|
||
From: callerID, To: "unresolved::*." + c.name,
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||
}
|
||
if recvType, ok := tenv[c.receiver]; ok {
|
||
edge.Meta = map[string]any{"receiver_type": recvType}
|
||
} else if strings.Contains(c.receiver, ".") || strings.Contains(c.receiver, "(") {
|
||
stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, tenv, result))
|
||
}
|
||
stampReturnUsage(edge, c.returnUsage)
|
||
result.Edges = append(result.Edges, edge)
|
||
continue
|
||
}
|
||
|
||
// Plain call. When the call name itself is bound by a
|
||
// `from X import Y [as Z]` (or `import X as Y`) statement,
|
||
// route the edge through the same module-attributed
|
||
// extern stub the attribute-call branch uses — that's
|
||
// what makes `from numpy import array; array(...)` and
|
||
// `import numpy as np; np.array(...)` both attribute to
|
||
// numpy after the resolver post-pass.
|
||
if importPath, ok := lookupPyImport(c.name, imports); ok {
|
||
edge := &graph.Edge{
|
||
From: callerID, To: "unresolved::extern::" + importPath + "::" + c.name,
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||
}
|
||
stampReturnUsage(edge, c.returnUsage)
|
||
result.Edges = append(result.Edges, edge)
|
||
} else {
|
||
edge := &graph.Edge{
|
||
From: callerID, To: "unresolved::" + c.name,
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||
}
|
||
stampReturnUsage(edge, c.returnUsage)
|
||
result.Edges = append(result.Edges, edge)
|
||
}
|
||
|
||
// FastAPI dependency injection: Depends(target) — emit a direct
|
||
// edge from the enclosing function to the first identifier
|
||
// argument of Depends so the target shows up as a caller
|
||
// relationship.
|
||
if c.name == "Depends" && c.expr != nil {
|
||
if dep := firstIdentifierArg(c.expr, src); dep != "" {
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: callerID, To: "unresolved::" + dep,
|
||
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
||
Meta: map[string]any{"via": "fastapi.Depends"},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Event pub/sub edges ---
|
||
pubsubImportPaths := importPathValues(imports)
|
||
var pubsubEvents []pubsubEvent
|
||
for _, c := range calls {
|
||
if !c.isAttr || c.expr == nil {
|
||
continue
|
||
}
|
||
if ev, ok := detectPyPubsubCall(c.expr, c.name, src, pubsubImportPaths, c.line); ok {
|
||
pubsubEvents = append(pubsubEvents, ev)
|
||
}
|
||
}
|
||
emitPubsubEvents(pubsubEvents,
|
||
func(line int) string { return findEnclosingFunc(funcRanges, line) },
|
||
filePath, "python", result)
|
||
|
||
// SQL function call sites (Supabase .rpc('fn'), SQLAlchemy func.fn()).
|
||
emitSQLCallsiteEdges(src, "python",
|
||
func(line int) string { return findEnclosingFunc(funcRanges, line) },
|
||
filePath, result)
|
||
|
||
MaybeEnrichDatabricks(filePath, fileID, src, result)
|
||
|
||
captureValueRefCandidates(result, root, filePath, src)
|
||
captureFnValueCandidates(result, root, filePath, src)
|
||
captureCeleryDispatch(result, root, filePath, src)
|
||
captureDjangoDescriptors(result, root, filePath, src)
|
||
captureFastAPIRouterRefs(result, root, filePath, src)
|
||
return result, nil
|
||
}
|
||
|
||
// --- Per-match emit helpers -----------------------------------------
|
||
|
||
// emitFunction handles every function_definition. The parent check
|
||
// (function → [decorated_definition →] block → class_definition)
|
||
// classifies direct children of a class body — decorated or not — as
|
||
// receiver-qualified methods. Nested functions land in the
|
||
// free-function bucket.
|
||
func (e *PythonExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
|
||
name := m.Captures["func.name"].Text
|
||
def := m.Captures["func.def"]
|
||
startLine1 := def.StartLine + 1
|
||
|
||
doc := pyDocstringFromDef(def.Node, src)
|
||
visibility := VisibilityByUnderscore(name)
|
||
decorators := pyDecoratorNodes(def.Node)
|
||
complexity, cognitive, loopDepth := 0, 0, 0
|
||
if def.Node != nil {
|
||
if body := def.Node.ChildByFieldName("body"); body != nil {
|
||
complexity, cognitive, loopDepth = BodyComplexityMetrics(body, "python")
|
||
}
|
||
}
|
||
|
||
className := pyDirectClassParent(def.Node, src)
|
||
if className != "" {
|
||
id, ok := disambiguateID(seen, filePath+"::"+className+"."+name, startLine1)
|
||
if !ok {
|
||
return
|
||
}
|
||
node := &graph.Node{
|
||
ID: id, Kind: graph.KindMethod, Name: name,
|
||
FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1,
|
||
Language: "python", Meta: map[string]any{
|
||
"receiver": className,
|
||
"signature": "def " + name + "(...)",
|
||
"visibility": visibility,
|
||
},
|
||
}
|
||
if doc != "" {
|
||
node.Meta["doc"] = doc
|
||
}
|
||
ApplyComplexityMeta(node.Meta, complexity, cognitive, loopDepth)
|
||
if def.Node != nil {
|
||
if rt := extractPyReturnType(def.Node, src); rt != "" {
|
||
node.Meta["return_type"] = rt
|
||
}
|
||
}
|
||
result.Nodes = append(result.Nodes, node)
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
|
||
})
|
||
typeID := filePath + "::" + className
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: id, To: typeID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1,
|
||
})
|
||
emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
|
||
emitPyThrowsEdges(def.Node, src, id, filePath, startLine1, result)
|
||
emitPyFunctionShape(id, def.Node, src, filePath, startLine1, result)
|
||
return
|
||
}
|
||
|
||
// Free function (top-level or nested in another function).
|
||
id, ok := disambiguateID(seen, filePath+"::"+name, startLine1)
|
||
if !ok {
|
||
return
|
||
}
|
||
meta := map[string]any{
|
||
"signature": "def " + name + "(...)",
|
||
"visibility": visibility,
|
||
}
|
||
if doc != "" {
|
||
meta["doc"] = doc
|
||
}
|
||
ApplyComplexityMeta(meta, complexity, cognitive, loopDepth)
|
||
result.Nodes = append(result.Nodes, &graph.Node{
|
||
ID: id, Kind: graph.KindFunction, Name: name,
|
||
FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1,
|
||
Language: "python", Meta: meta,
|
||
})
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
|
||
})
|
||
emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
|
||
emitPyThrowsEdges(def.Node, src, id, filePath, startLine1, result)
|
||
emitPyFunctionShape(id, def.Node, src, filePath, startLine1, result)
|
||
}
|
||
|
||
// emitPyThrowsEdges walks a function/method body for raise_statement
|
||
// nodes and emits an EdgeThrows per distinct exception name.
|
||
// `raise SomeError` and `raise SomeError("...")` both surface; bare
|
||
// `raise` (re-raise) is skipped because there's no specific type.
|
||
// `Origin: ASTInferred` because Python doesn't enforce a checked-
|
||
// exception contract — the body scan is a best-effort summary, not
|
||
// a proof of every exception that can propagate.
|
||
func emitPyThrowsEdges(funcNode *sitter.Node, src []byte, fromID, filePath string, fromLine int, result *parser.ExtractionResult) {
|
||
if funcNode == nil {
|
||
return
|
||
}
|
||
body := funcNode.ChildByFieldName("body")
|
||
if body == nil {
|
||
return
|
||
}
|
||
seen := map[string]bool{}
|
||
pyWalkRaises(body, src, fromID, filePath, fromLine, seen, result)
|
||
}
|
||
|
||
func pyWalkRaises(node *sitter.Node, src []byte, fromID, filePath string, fromLine int, seen map[string]bool, result *parser.ExtractionResult) {
|
||
if node == nil {
|
||
return
|
||
}
|
||
if node.Type() == "raise_statement" {
|
||
name := pyRaiseExceptionName(node, src)
|
||
if name != "" && !seen[name] {
|
||
seen[name] = true
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fromID,
|
||
To: "unresolved::" + name,
|
||
Kind: graph.EdgeThrows,
|
||
FilePath: filePath,
|
||
Line: int(node.StartPoint().Row) + 1,
|
||
Origin: graph.OriginASTInferred,
|
||
})
|
||
}
|
||
return
|
||
}
|
||
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
|
||
c := node.NamedChild(i)
|
||
// Don't descend into nested function/class bodies — their raises
|
||
// belong to those functions, not to us.
|
||
if c == nil {
|
||
continue
|
||
}
|
||
if c.Type() == "function_definition" || c.Type() == "class_definition" || c.Type() == "decorated_definition" || c.Type() == "lambda" {
|
||
continue
|
||
}
|
||
pyWalkRaises(c, src, fromID, filePath, fromLine, seen, result)
|
||
}
|
||
}
|
||
|
||
// pyRaiseExceptionName returns the exception type name from a
|
||
// raise_statement. Handles `raise X`, `raise X("msg")`, `raise X(...)`,
|
||
// and chained `raise X from Y` (we record X). Returns "" for bare
|
||
// `raise` (re-raise).
|
||
func pyRaiseExceptionName(raise *sitter.Node, src []byte) string {
|
||
for i, _nc := 0, int(raise.NamedChildCount()); i < _nc; i++ {
|
||
c := raise.NamedChild(i)
|
||
if c == nil {
|
||
continue
|
||
}
|
||
switch c.Type() {
|
||
case "identifier":
|
||
return c.Content(src)
|
||
case "attribute":
|
||
// e.g. errors.MyError → take the trailing attribute name.
|
||
text := c.Content(src)
|
||
if i := strings.LastIndex(text, "."); i >= 0 {
|
||
return strings.TrimSpace(text[i+1:])
|
||
}
|
||
return strings.TrimSpace(text)
|
||
case "call":
|
||
fn := c.ChildByFieldName("function")
|
||
if fn == nil {
|
||
continue
|
||
}
|
||
text := fn.Content(src)
|
||
if i := strings.LastIndex(text, "."); i >= 0 {
|
||
return strings.TrimSpace(text[i+1:])
|
||
}
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (e *PythonExtractor) emitClass(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
|
||
name := m.Captures["class.name"].Text
|
||
def := m.Captures["class.def"]
|
||
id := filePath + "::" + name
|
||
decorators := pyDecoratorNodes(def.Node)
|
||
if seen[id] {
|
||
emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
|
||
return
|
||
}
|
||
seen[id] = true
|
||
meta := map[string]any{"visibility": VisibilityByUnderscore(name), "type_flavor": "class"}
|
||
if doc := pyDocstringFromDef(def.Node, src); doc != "" {
|
||
meta["doc"] = doc
|
||
}
|
||
result.Nodes = append(result.Nodes, &graph.Node{
|
||
ID: id, Kind: graph.KindType, Name: name,
|
||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||
Language: "python",
|
||
Meta: meta,
|
||
})
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||
})
|
||
emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
|
||
// PEP-695 generic class declarations (`class Foo[T]:`) carry a
|
||
// `type_parameters` child same as functions; reuse the helper.
|
||
emitPyGenericParamNodes(id, def.Node, src, filePath, def.StartLine+1, result)
|
||
// ORM model attribution: emit EdgeModelsTable when the class
|
||
// inherits from a known ORM base (SQLAlchemy / Django).
|
||
detectPythonORMModel(def.Node, src, id, name, filePath, result)
|
||
}
|
||
|
||
// pyDecoratorNodes returns the `decorator` AST nodes attached to a
|
||
// function_definition or class_definition. In tree-sitter Python the
|
||
// decorators wrap the def in a `decorated_definition` parent —
|
||
// children of that parent that come before the def are the decorators.
|
||
func pyDecoratorNodes(defNode *sitter.Node) []*sitter.Node {
|
||
if defNode == nil {
|
||
return nil
|
||
}
|
||
parent := defNode.Parent()
|
||
if parent == nil || parent.Type() != "decorated_definition" {
|
||
return nil
|
||
}
|
||
var out []*sitter.Node
|
||
for i, _nc := 0, int(parent.ChildCount()); i < _nc; i++ {
|
||
c := parent.Child(i)
|
||
if c != nil && c.Type() == "decorator" {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// emitPyAnnotationEdges emits an EdgeAnnotated edge per decorator node
|
||
// applied to the function/class identified by `fromID`. The decorator
|
||
// expression after the `@` is taken as-is; identifier-only decorators
|
||
// (`@deprecated`) and call decorators (`@app.route("/x")`) both map
|
||
// to the bare callable name.
|
||
func emitPyAnnotationEdges(decorators []*sitter.Node, fromID, filePath string, src []byte, result *parser.ExtractionResult, seen map[string]bool) {
|
||
for _, dec := range decorators {
|
||
name, args := pyDecoratorNameAndArgs(dec, src)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
EmitAnnotationEdge(fromID, "python", name, args, filePath, int(dec.StartPoint().Row)+1, result, seen)
|
||
}
|
||
}
|
||
|
||
// pyDecoratorNameAndArgs reads a `decorator` AST node. Tree-sitter
|
||
// Python wraps the post-`@` expression as the named child — typically
|
||
// `identifier`, `attribute`, or `call`.
|
||
func pyDecoratorNameAndArgs(dec *sitter.Node, src []byte) (string, string) {
|
||
if dec == nil {
|
||
return "", ""
|
||
}
|
||
for i, _nc := 0, int(dec.NamedChildCount()); i < _nc; i++ {
|
||
c := dec.NamedChild(i)
|
||
if c == nil {
|
||
continue
|
||
}
|
||
switch c.Type() {
|
||
case "identifier", "attribute", "dotted_name":
|
||
return c.Content(src), ""
|
||
case "call":
|
||
fn := c.ChildByFieldName("function")
|
||
args := c.ChildByFieldName("arguments")
|
||
name := ""
|
||
if fn != nil {
|
||
name = fn.Content(src)
|
||
}
|
||
argText := ""
|
||
if args != nil {
|
||
argText = args.Content(src)
|
||
if len(argText) >= 2 && argText[0] == '(' && argText[len(argText)-1] == ')' {
|
||
argText = argText[1 : len(argText)-1]
|
||
}
|
||
}
|
||
return name, argText
|
||
}
|
||
}
|
||
return "", ""
|
||
}
|
||
|
||
// pyDocstringFromDef returns the docstring of a function_definition or
|
||
// class_definition node — the first string literal in the body block —
|
||
// or "" when none is present. Returns "" for nil nodes.
|
||
func pyDocstringFromDef(defNode *sitter.Node, src []byte) string {
|
||
if defNode == nil {
|
||
return ""
|
||
}
|
||
body := defNode.ChildByFieldName("body")
|
||
if body == nil {
|
||
return ""
|
||
}
|
||
for i, _nc := 0, int(body.NamedChildCount()); i < _nc; i++ {
|
||
stmt := body.NamedChild(i)
|
||
if stmt == nil {
|
||
continue
|
||
}
|
||
if stmt.Type() != "expression_statement" {
|
||
return ""
|
||
}
|
||
// Walk into expression_statement to find a string literal.
|
||
for j, _nc := 0, int(stmt.NamedChildCount()); j < _nc; j++ {
|
||
c := stmt.NamedChild(j)
|
||
if c == nil {
|
||
continue
|
||
}
|
||
if c.Type() == "string" {
|
||
return ExtractPyDocstring(c.Content(src))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// emitImport handles `import os`, `import os.path`, `import numpy as np`.
|
||
// Walks the import_statement node to populate the alias→module map used
|
||
// by attribute-call classification.
|
||
func (e *PythonExtractor) emitImport(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, imports map[string]string) {
|
||
name := m.Captures["import.name"]
|
||
pyEmitImportNode(filePath, fileID, name.Text, "", name.StartLine+1, result)
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: "unresolved::import::" + name.Text,
|
||
Kind: graph.EdgeImports, FilePath: filePath, Line: name.StartLine + 1,
|
||
})
|
||
def, ok := m.Captures["import.def"]
|
||
if !ok || def.Node == nil {
|
||
return
|
||
}
|
||
stmt := def.Node
|
||
for i, _nc := 0, int(stmt.NamedChildCount()); i < _nc; i++ {
|
||
child := stmt.NamedChild(i)
|
||
switch child.Type() {
|
||
case "dotted_name":
|
||
dotted := child.Content(src)
|
||
alias := dotted
|
||
if j := strings.Index(dotted, "."); j >= 0 {
|
||
alias = dotted[:j] // `import os.path` binds `os`
|
||
}
|
||
imports[alias] = dotted
|
||
case "aliased_import":
|
||
var modulePath, alias string
|
||
for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ {
|
||
cc := child.NamedChild(j)
|
||
switch cc.Type() {
|
||
case "dotted_name":
|
||
modulePath = cc.Content(src)
|
||
case "identifier":
|
||
alias = cc.Content(src)
|
||
}
|
||
}
|
||
if alias != "" && modulePath != "" {
|
||
imports[alias] = modulePath
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// emitImportFrom handles `from X import Y`, `from X import Y as Z`, and
|
||
// `from X import Y, Z`. Each imported name is registered in the per-
|
||
// file alias map so attribute-style calls (`Y(...)`) — and any later
|
||
// `Z(...)` aliases — resolve through `lookupPyImport` to the right
|
||
// dotted module path. Without this, every `from`-imported name fell
|
||
// through to the bare-call branch and never attributed to its module.
|
||
func (e *PythonExtractor) emitImportFrom(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, imports map[string]string) {
|
||
mod := m.Captures["import.module"]
|
||
pyEmitImportNode(filePath, fileID, mod.Text, "", mod.StartLine+1, result)
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: "unresolved::import::" + mod.Text,
|
||
Kind: graph.EdgeImports, FilePath: filePath, Line: mod.StartLine + 1,
|
||
})
|
||
|
||
def, ok := m.Captures["importfrom.def"]
|
||
if !ok || def.Node == nil {
|
||
return
|
||
}
|
||
stmt := def.Node
|
||
// Walk the statement's named children. The module is the first
|
||
// `dotted_name` (already captured); subsequent `dotted_name`
|
||
// children are imported names. `aliased_import` children carry
|
||
// the optional `as <alias>`. `wildcard_import` (`from X import *`)
|
||
// is intentionally skipped — there's no specific name to bind.
|
||
moduleSeen := false
|
||
for i, _nc := 0, int(stmt.NamedChildCount()); i < _nc; i++ {
|
||
child := stmt.NamedChild(i)
|
||
switch child.Type() {
|
||
case "dotted_name":
|
||
if !moduleSeen {
|
||
moduleSeen = true
|
||
continue
|
||
}
|
||
name := child.Content(src)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
imports[name] = mod.Text + "." + name
|
||
case "aliased_import":
|
||
var importedName, alias string
|
||
for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ {
|
||
cc := child.NamedChild(j)
|
||
switch cc.Type() {
|
||
case "dotted_name":
|
||
importedName = cc.Content(src)
|
||
case "identifier":
|
||
alias = cc.Content(src)
|
||
}
|
||
}
|
||
if importedName == "" || alias == "" {
|
||
continue
|
||
}
|
||
imports[alias] = mod.Text + "." + importedName
|
||
}
|
||
}
|
||
}
|
||
|
||
// emitImportFromRelative handles `from . import foo`, `from .sub import
|
||
// bar`, `from ..pkg.deep import x` shapes whose `module_name` is a
|
||
// `relative_import` rather than a `dotted_name`. Without this branch the
|
||
// parser silently dropped every relative import, leaving the graph
|
||
// blind to in-project package edges.
|
||
//
|
||
// The handler maps the relative reference to a project-rooted file-path
|
||
// stem so the resolver post-pass `resolveRelativeImports` can land the
|
||
// edge on the actual `KindFile` node. Imported names are bound in the
|
||
// per-file alias map with a `pyrel::<stem>` marker; the call-resolution
|
||
// loop in Extract recognises that shape and emits dataflow edges that
|
||
// the post-pass also rewrites onto internal file symbols.
|
||
func (e *PythonExtractor) emitImportFromRelative(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, _ map[string]string) {
|
||
relCap := m.Captures["importfrom.rel"]
|
||
if relCap.Node == nil {
|
||
return
|
||
}
|
||
relNode := relCap.Node
|
||
dots := 0
|
||
modPath := ""
|
||
for i, _nc := 0, int(relNode.NamedChildCount()); i < _nc; i++ {
|
||
child := relNode.NamedChild(i)
|
||
switch child.Type() {
|
||
case "import_prefix":
|
||
dots += strings.Count(child.Content(src), ".")
|
||
case "dotted_name":
|
||
modPath = child.Content(src)
|
||
}
|
||
}
|
||
// Some grammar versions place the dot prefix in unnamed children;
|
||
// scan all children for `.` tokens when the named-child walk yields
|
||
// zero dots so we don't silently drop `from . import x`.
|
||
if dots == 0 {
|
||
for i, _nc := 0, int(relNode.ChildCount()); i < _nc; i++ {
|
||
child := relNode.Child(i)
|
||
if child.Type() == "." {
|
||
dots++
|
||
}
|
||
}
|
||
}
|
||
if dots == 0 {
|
||
return
|
||
}
|
||
|
||
stem := pyResolveRelativeStem(filePath, dots, modPath)
|
||
if stem == "" {
|
||
return
|
||
}
|
||
importLine := int(relNode.StartPoint().Row) + 1
|
||
|
||
// When the relative import names a module (`from .util import a`),
|
||
// emit one EdgeImports edge to the module stem — the imported
|
||
// names live inside that module. When the dot prefix carries no
|
||
// module name (`from . import util`), each imported NAME is itself
|
||
// a submodule of the current package, so emit one edge per name
|
||
// targeting `<stem>/<name>`. This is the same shape the absolute
|
||
// `from numpy import array` path uses (one import edge to the
|
||
// originating module) — the only difference is that for `from .
|
||
// import X` the module name comes from the import list.
|
||
if modPath != "" {
|
||
pyEmitImportNode(filePath, fileID, modPath, "", importLine, result)
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: "unresolved::pyrel::" + stem,
|
||
Kind: graph.EdgeImports, FilePath: filePath, Line: importLine,
|
||
})
|
||
}
|
||
|
||
def, ok := m.Captures["importfrom_rel.def"]
|
||
if !ok || def.Node == nil {
|
||
return
|
||
}
|
||
stmt := def.Node
|
||
moduleSeen := false
|
||
emittedSub := map[string]bool{}
|
||
emitSubmodule := func(name, alias string) {
|
||
if modPath != "" || name == "" {
|
||
return
|
||
}
|
||
subStem := stem + "/" + name
|
||
if emittedSub[subStem] {
|
||
return
|
||
}
|
||
emittedSub[subStem] = true
|
||
pyEmitImportNode(filePath, fileID, name, alias, importLine, result)
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: "unresolved::pyrel::" + subStem,
|
||
Kind: graph.EdgeImports, FilePath: filePath, Line: importLine,
|
||
})
|
||
}
|
||
for i, _nc := 0, int(stmt.NamedChildCount()); i < _nc; i++ {
|
||
child := stmt.NamedChild(i)
|
||
switch child.Type() {
|
||
case "relative_import":
|
||
moduleSeen = true
|
||
continue
|
||
case "dotted_name":
|
||
if !moduleSeen {
|
||
moduleSeen = true
|
||
continue
|
||
}
|
||
name := child.Content(src)
|
||
emitSubmodule(name, "")
|
||
case "aliased_import":
|
||
var importedName, alias string
|
||
for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ {
|
||
cc := child.NamedChild(j)
|
||
switch cc.Type() {
|
||
case "dotted_name":
|
||
importedName = cc.Content(src)
|
||
case "identifier":
|
||
alias = cc.Content(src)
|
||
}
|
||
}
|
||
emitSubmodule(importedName, alias)
|
||
}
|
||
}
|
||
}
|
||
|
||
// pyResolveRelativeStem maps a (filePath, dotCount, modPath) triple to
|
||
// the project-rooted file-path stem the import targets. Returns "" when
|
||
// the dot prefix would walk to or above the repo root — CPython raises
|
||
// ImportError on the same shape, so we refuse rather than invent a
|
||
// project-root pseudo-package.
|
||
//
|
||
// pyResolveRelativeStem("app/main.py", 1, "") → "app"
|
||
// pyResolveRelativeStem("app/main.py", 1, "util") → "app/util"
|
||
// pyResolveRelativeStem("app/sub/x.py", 2, "parent") → "app/parent"
|
||
// pyResolveRelativeStem("app/sub/x.py", 3, "y") → "" (above root)
|
||
// pyResolveRelativeStem("main.py", 1, "x") → "" (no package)
|
||
func pyResolveRelativeStem(filePath string, dots int, modPath string) string {
|
||
if dots <= 0 || filePath == "" {
|
||
return ""
|
||
}
|
||
dir := ""
|
||
if i := strings.LastIndex(filePath, "/"); i >= 0 {
|
||
dir = filePath[:i]
|
||
}
|
||
levels := 0
|
||
if dir != "" {
|
||
levels = strings.Count(dir, "/") + 1
|
||
}
|
||
// `dots-1` is the number of parent-package walks. Walking to or
|
||
// past `levels` means we'd land at (or above) the implicit repo
|
||
// root with no real package — refuse rather than fabricate one.
|
||
if dots-1 >= levels {
|
||
return ""
|
||
}
|
||
for i := 1; i < dots; i++ {
|
||
if j := strings.LastIndex(dir, "/"); j >= 0 {
|
||
dir = dir[:j]
|
||
} else {
|
||
dir = ""
|
||
}
|
||
}
|
||
if modPath == "" {
|
||
return dir
|
||
}
|
||
suffix := strings.ReplaceAll(modPath, ".", "/")
|
||
if dir == "" {
|
||
return suffix
|
||
}
|
||
return dir + "/" + suffix
|
||
}
|
||
|
||
// pyEmitImportNode appends a KindImport node + Defines edge for a
|
||
// Python `import X` or `from X import …` statement. is_external is
|
||
// true when the path doesn't begin with a relative prefix and isn't
|
||
// in the small stdlib whitelist — close enough for routing UX while
|
||
// being trivially cheap.
|
||
func pyEmitImportNode(filePath, fileID, importPath, alias string, line int, result *parser.ExtractionResult) {
|
||
if importPath == "" {
|
||
return
|
||
}
|
||
importNodeID := filePath + "::import::" + importPath
|
||
meta := map[string]any{
|
||
"path": importPath,
|
||
"is_external": isExternalPyImport(importPath),
|
||
}
|
||
if alias != "" {
|
||
meta["alias"] = alias
|
||
}
|
||
result.Nodes = append(result.Nodes, &graph.Node{
|
||
ID: importNodeID,
|
||
Kind: graph.KindImport,
|
||
Name: pyImportDisplayName(importPath),
|
||
FilePath: filePath,
|
||
StartLine: line,
|
||
EndLine: line,
|
||
Language: "python",
|
||
Meta: meta,
|
||
})
|
||
// File → import-node uses EdgeContains (the file contains an
|
||
// import statement; it doesn't define the imported module).
|
||
// GetFileSubGraph walks EdgeDefines ∪ EdgeContains to recover the
|
||
// full file neighbourhood.
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: importNodeID,
|
||
Kind: graph.EdgeContains, FilePath: filePath, Line: line,
|
||
})
|
||
}
|
||
|
||
func isExternalPyImport(path string) bool {
|
||
if path == "" || strings.HasPrefix(path, ".") {
|
||
return false
|
||
}
|
||
// Best-effort stdlib check: a small whitelist of common stdlib
|
||
// roots avoids tagging them as external. False negatives are
|
||
// harmless (the agent can still query the import), false
|
||
// positives would mislead "is this a third-party dep?" queries.
|
||
first := path
|
||
if i := strings.Index(path, "."); i >= 0 {
|
||
first = path[:i]
|
||
}
|
||
switch first {
|
||
case "os", "sys", "io", "re", "json", "math", "random", "time",
|
||
"datetime", "collections", "itertools", "functools", "typing",
|
||
"asyncio", "logging", "pathlib", "subprocess", "threading",
|
||
"multiprocessing", "abc", "enum", "dataclasses", "contextlib",
|
||
"copy", "tempfile", "shutil", "string", "struct", "hashlib",
|
||
"unittest", "ast", "types", "warnings", "weakref", "inspect":
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func pyImportDisplayName(path string) string {
|
||
if i := strings.LastIndex(path, "."); i >= 0 {
|
||
return path[i+1:]
|
||
}
|
||
return path
|
||
}
|
||
|
||
func (e *PythonExtractor) emitTopLevelVar(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool) {
|
||
name := m.Captures["var.name"].Text
|
||
def := m.Captures["var.def"]
|
||
if def.Node == nil || def.Node.Parent() == nil || def.Node.Parent().Type() != "module" {
|
||
return
|
||
}
|
||
id := filePath + "::" + name
|
||
if seen[id] || strings.HasPrefix(name, "_") {
|
||
return
|
||
}
|
||
seen[id] = true
|
||
result.Nodes = append(result.Nodes, &graph.Node{
|
||
ID: id, Kind: graph.KindVariable, Name: name,
|
||
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
||
Language: "python",
|
||
})
|
||
result.Edges = append(result.Edges, &graph.Edge{
|
||
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
||
})
|
||
}
|
||
|
||
// --- Helpers --------------------------------------------------------
|
||
|
||
// pyDirectClassParent returns the enclosing class name when fn is a
|
||
// direct child of a class_definition's body block. Decorated methods
|
||
// (`@property def encoding`, `@contextmanager def stream`, …) are
|
||
// wrapped in a `decorated_definition` node between the def and the
|
||
// class body — the walk hops over that wrapper so they classify as
|
||
// methods of the class, exactly like their undecorated siblings.
|
||
// Without the hop, two same-named decorated members of different
|
||
// classes in one file (httpx's Headers.encoding / Response.encoding)
|
||
// collapsed into bare colliding `<file>::<name>` IDs, and every
|
||
// consumer addressing the class-qualified ID silently landed on the
|
||
// wrong declaration. Returns "" for nested functions and top-level
|
||
// functions.
|
||
func pyDirectClassParent(fn *sitter.Node, src []byte) string {
|
||
if fn == nil {
|
||
return ""
|
||
}
|
||
parent := fn.Parent()
|
||
if parent != nil && parent.Type() == "decorated_definition" {
|
||
parent = parent.Parent()
|
||
}
|
||
if parent == nil || parent.Type() != "block" {
|
||
return ""
|
||
}
|
||
grand := parent.Parent()
|
||
if grand == nil || grand.Type() != "class_definition" {
|
||
return ""
|
||
}
|
||
nameNode := grand.ChildByFieldName("name")
|
||
if nameNode == nil {
|
||
return ""
|
||
}
|
||
return nameNode.Content(src)
|
||
}
|
||
|
||
// firstIdentifierArg returns the string content of the first positional
|
||
// argument to a Python call_expression when that argument is a bare
|
||
// identifier (function name or class name), or "" otherwise. Used for
|
||
// FastAPI's Depends(target) where we want the target, not Depends
|
||
// itself, to show up as the called symbol. Non-identifier arguments —
|
||
// lambdas, attribute access, calls — are skipped because they can't
|
||
// be statically resolved to a graph node.
|
||
func firstIdentifierArg(callNode *sitter.Node, src []byte) string {
|
||
args := callNode.ChildByFieldName("arguments")
|
||
if args == nil {
|
||
return ""
|
||
}
|
||
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
||
arg := args.NamedChild(i)
|
||
if arg == nil {
|
||
continue
|
||
}
|
||
// Skip keyword arguments — Depends(use_cache=False, ...) shouldn't
|
||
// produce a call edge to `False`.
|
||
if arg.Type() == "keyword_argument" {
|
||
continue
|
||
}
|
||
if arg.Type() == "identifier" {
|
||
return arg.Content(src)
|
||
}
|
||
return ""
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// extractPyReturnType walks a function_definition node for a return_type child
|
||
// (the `-> Type` annotation) and returns the normalized type name.
|
||
func extractPyReturnType(funcNode *sitter.Node, src []byte) string {
|
||
for i, _nc := 0, int(funcNode.NamedChildCount()); i < _nc; i++ {
|
||
child := funcNode.NamedChild(i)
|
||
if child.Type() == "type" {
|
||
// Check if preceding sibling token is "->".
|
||
// In tree-sitter Python grammar, the return type is a "type" child
|
||
// that appears after the parameters.
|
||
return normalizePyTypeName(child.Content(src))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// normalizePyTypeName strips Optional[], List[], etc. and skips builtins.
|
||
func normalizePyTypeName(t string) string {
|
||
t = strings.TrimSpace(t)
|
||
// Strip Optional[...], List[...], etc.
|
||
if idx := strings.Index(t, "["); idx > 0 {
|
||
t = t[:idx]
|
||
}
|
||
switch t {
|
||
case "int", "float", "str", "bool", "bytes", "None", "list", "dict", "set", "tuple", "object":
|
||
return ""
|
||
}
|
||
if t == "" || (t[0] >= 'a' && t[0] <= 'z') {
|
||
return ""
|
||
}
|
||
return t
|
||
}
|
||
|
||
// lookupPyImport resolves a dotted Python receiver to an import path.
|
||
// Tries the full receiver first, then progressively shorter prefixes
|
||
// (os.path → os), so `os.path.join(x)` with `import os.path` finds the
|
||
// right module.
|
||
func lookupPyImport(receiver string, imports map[string]string) (string, bool) {
|
||
if p, ok := imports[receiver]; ok {
|
||
return p, true
|
||
}
|
||
for i := strings.LastIndex(receiver, "."); i > 0; i = strings.LastIndex(receiver[:i], ".") {
|
||
prefix := receiver[:i]
|
||
if p, ok := imports[prefix]; ok {
|
||
return p, true
|
||
}
|
||
}
|
||
if i := strings.Index(receiver, "."); i > 0 {
|
||
if p, ok := imports[receiver[:i]]; ok {
|
||
return p, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|