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
1327 lines
44 KiB
Go
1327 lines
44 KiB
Go
package languages
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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/kotlin"
|
|
)
|
|
|
|
// qKotlinAll is a single tree-sitter query alternating over every
|
|
// pattern the Kotlin extractor needs. One tree walk per file replaces
|
|
// the 10 `parser.RunQuery` calls plus the 5 walkNodes traversals the
|
|
// previous design made. Capture names are disjoint across patterns so
|
|
// the dispatch in Extract can branch on which name is set. Member
|
|
// containment for class/object methods, the class-vs-interface-vs-enum
|
|
// distinction, and enum entries are all resolved inline by inspecting
|
|
// the captured node — the legacy walkNodes pass over class_declaration
|
|
// nodes is collapsed into the alternation dispatch.
|
|
const qKotlinAll = `
|
|
[
|
|
(class_declaration
|
|
(type_identifier) @class.name) @class.def
|
|
|
|
(object_declaration
|
|
(type_identifier) @obj.name) @obj.def
|
|
|
|
(function_declaration
|
|
(simple_identifier) @func.name) @func.def
|
|
|
|
(property_declaration
|
|
(variable_declaration
|
|
(simple_identifier) @prop.name)) @prop.def
|
|
|
|
(property_declaration
|
|
(variable_declaration
|
|
(simple_identifier) @tprop.name
|
|
(user_type) @tprop.type)) @tprop.def
|
|
|
|
(import_header
|
|
(identifier) @import.path) @import.def
|
|
|
|
(call_expression
|
|
(simple_identifier) @call.name) @call.expr
|
|
|
|
(call_expression
|
|
(navigation_expression
|
|
(_) @callm.receiver
|
|
(navigation_suffix
|
|
(simple_identifier) @callm.method))) @callm.expr
|
|
]
|
|
`
|
|
|
|
// KotlinExtractor extracts Kotlin source files into graph nodes and edges.
|
|
type KotlinExtractor struct {
|
|
lang *sitter.Language
|
|
qAll *parser.PreparedQuery
|
|
}
|
|
|
|
func NewKotlinExtractor() *KotlinExtractor {
|
|
lang := kotlin.GetLanguage()
|
|
return &KotlinExtractor{
|
|
lang: lang,
|
|
qAll: parser.MustPreparedQuery(qKotlinAll, lang),
|
|
}
|
|
}
|
|
|
|
func (e *KotlinExtractor) Language() string { return "kotlin" }
|
|
func (e *KotlinExtractor) Extensions() []string { return []string{".kt", ".kts"} }
|
|
|
|
// --- Deferred match buffers ----------------------------------------
|
|
|
|
type kotlinDeferredCall struct {
|
|
name string
|
|
receiver string
|
|
line int
|
|
isMember bool
|
|
}
|
|
|
|
// kotlinDeferredProperty buffers a property_declaration for the
|
|
// post-pass type-env build. Mirrors legacy precedence: Tier 0
|
|
// (explicit user_type) overwrites; Tier 1 (`val x = Foo()`) only
|
|
// fills in keys without an explicit type.
|
|
type kotlinDeferredProperty struct {
|
|
name string
|
|
explicit string // normalized type from explicit annotation, "" if none
|
|
defNode *sitter.Node // property_declaration node, for Tier 1 walk
|
|
atSourceTop bool // direct child of source_file → emit as top-level var
|
|
line int
|
|
endLine int
|
|
}
|
|
|
|
// kotlinTypeUse buffers a variable/property annotation (`val x: T`,
|
|
// `var x: T`) for the post-pass that emits EdgeTypedAs from the enclosing
|
|
// function (or the file node when the annotation is top-level). The
|
|
// package has no shared deferredTypeUse type, so this mirrors the TS
|
|
// deferredTypeUse struct locally. typeText is the verbatim annotation
|
|
// source; it is normalized at emit time by emitKotlinTypeUseEdges.
|
|
type kotlinTypeUse struct {
|
|
typeText string
|
|
line int
|
|
}
|
|
|
|
// kotlinLambdaScope records the parameters bound by one lambda literal
|
|
// over the line span of its body. A use of such a parameter inside the
|
|
// span is locally bound and must not be resolved against the outer type
|
|
// environment (which would mis-attribute a `receiver_type` to an
|
|
// unrelated outer variable / type of the same name). Implicit `it` is
|
|
// always in scope inside a lambda that declares no explicit params.
|
|
type kotlinLambdaScope struct {
|
|
params map[string]bool
|
|
startLine int
|
|
endLine int
|
|
}
|
|
|
|
func (e *KotlinExtractor) 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: "kotlin",
|
|
}
|
|
fileID := fileNode.ID
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
seen := make(map[string]bool)
|
|
annotationSeen := make(map[string]bool)
|
|
|
|
var calls []kotlinDeferredCall
|
|
var props []kotlinDeferredProperty
|
|
var typeUses []kotlinTypeUse
|
|
|
|
parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) {
|
|
switch {
|
|
|
|
case m.Captures["class.def"] != nil:
|
|
e.emitClassOrInterface(m, filePath, fileID, src, result, seen, annotationSeen)
|
|
|
|
case m.Captures["obj.def"] != nil:
|
|
e.emitObject(m, filePath, fileID, src, result, seen, annotationSeen)
|
|
|
|
case m.Captures["func.def"] != nil:
|
|
e.emitFunction(m, filePath, fileID, src, result, seen, annotationSeen)
|
|
|
|
case m.Captures["tprop.def"] != nil:
|
|
// Tier 0 of tenv arrives via this disjoint pattern; we still
|
|
// also see the same property via prop.def below for top-level
|
|
// node emission and Tier 1 fallback.
|
|
name := m.Captures["tprop.name"].Text
|
|
rawType := m.Captures["tprop.type"].Text
|
|
typeName := normalizeKotlinTypeName(rawType)
|
|
if typeName != "" {
|
|
// Stash on the matching prop entry by appending a sentinel;
|
|
// we'll merge in the post-pass. Use a separate slice keyed
|
|
// by name to avoid relying on capture-arrival order.
|
|
props = append(props, kotlinDeferredProperty{
|
|
name: name,
|
|
explicit: typeName,
|
|
})
|
|
}
|
|
// Buffer the annotation for a type-use edge: every `val x: T` /
|
|
// `var x: T` (local, property, or top-level) references type T.
|
|
// The owner (enclosing function, else the file node) is resolved
|
|
// in the post-pass once funcRanges is built.
|
|
typeUses = append(typeUses, kotlinTypeUse{
|
|
typeText: rawType,
|
|
line: m.Captures["tprop.def"].StartLine + 1,
|
|
})
|
|
|
|
case m.Captures["prop.def"] != nil:
|
|
def := m.Captures["prop.def"]
|
|
top := def.Node != nil && def.Node.Parent() != nil && def.Node.Parent().Type() == "source_file"
|
|
props = append(props, kotlinDeferredProperty{
|
|
name: m.Captures["prop.name"].Text,
|
|
defNode: def.Node,
|
|
atSourceTop: top,
|
|
line: def.StartLine + 1,
|
|
endLine: def.EndLine + 1,
|
|
})
|
|
|
|
case m.Captures["import.def"] != nil:
|
|
e.emitImport(m, filePath, fileID, result)
|
|
|
|
case m.Captures["callm.expr"] != nil:
|
|
expr := m.Captures["callm.expr"]
|
|
calls = append(calls, kotlinDeferredCall{
|
|
name: m.Captures["callm.method"].Text,
|
|
receiver: m.Captures["callm.receiver"].Text,
|
|
line: expr.StartLine + 1,
|
|
isMember: true,
|
|
})
|
|
|
|
case m.Captures["call.expr"] != nil:
|
|
expr := m.Captures["call.expr"]
|
|
calls = append(calls, kotlinDeferredCall{
|
|
name: m.Captures["call.name"].Text,
|
|
line: expr.StartLine + 1,
|
|
})
|
|
}
|
|
})
|
|
|
|
// Build type environment in legacy precedence (Tier 0 overwrites,
|
|
// Tier 1 only fills gaps), and emit top-level property nodes from
|
|
// the same buffer.
|
|
tenv := make(typeEnv)
|
|
for _, p := range props {
|
|
if p.explicit != "" {
|
|
tenv[p.name] = p.explicit
|
|
}
|
|
}
|
|
for _, p := range props {
|
|
if p.explicit != "" {
|
|
continue
|
|
}
|
|
if _, exists := tenv[p.name]; exists {
|
|
continue
|
|
}
|
|
if p.defNode == nil {
|
|
continue
|
|
}
|
|
walkNodes(p.defNode, func(n *sitter.Node) {
|
|
if n.Type() != "call_expression" || n.NamedChildCount() == 0 {
|
|
return
|
|
}
|
|
nameNode := n.NamedChild(0)
|
|
if nameNode == nil || nameNode.Type() != "simple_identifier" {
|
|
return
|
|
}
|
|
funcName := nameNode.Content(src)
|
|
if len(funcName) > 0 && funcName[0] >= 'A' && funcName[0] <= 'Z' {
|
|
tenv[p.name] = funcName
|
|
}
|
|
})
|
|
}
|
|
for _, p := range props {
|
|
if !p.atSourceTop {
|
|
continue
|
|
}
|
|
id := filePath + "::" + p.name
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
// A top-level `const val` is a compile-time constant; classify it as
|
|
// KindConstant so it joins the value-reference impact surface.
|
|
propKind := graph.KindVariable
|
|
if kotlinPropertyIsConst(p.defNode, src) {
|
|
propKind = graph.KindConstant
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: propKind, Name: p.name,
|
|
FilePath: filePath, StartLine: p.line, EndLine: p.endLine,
|
|
Language: "kotlin",
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: p.line,
|
|
})
|
|
}
|
|
|
|
// Companion-object properties (`companion object { const val NAME; val x }`)
|
|
// are statically accessible on the enclosing TYPE (`Foo.NAME`). Emit
|
|
// them as members of that type so the constant / property is
|
|
// discoverable, mirroring the companion-function attribution above.
|
|
for _, p := range props {
|
|
if p.defNode == nil {
|
|
continue
|
|
}
|
|
owner := kotlinResolveMemberOwner(p.defNode, src)
|
|
if !owner.companion || owner.name == "" {
|
|
continue
|
|
}
|
|
emitKotlinCompanionProperty(p, owner, filePath, src, result, seen)
|
|
}
|
|
|
|
// Type-name set for static (companion-object) dispatch. A call whose
|
|
// receiver names a class / object — or a named-companion alias
|
|
// (`Type.Companion`) — is a static access on the TYPE; attribute the
|
|
// receiver_type to the type name itself so it resolves to the
|
|
// companion's member (emitted with Meta["receiver"] = <enclosing type>).
|
|
typeNames := kotlinCollectTypeNames(result)
|
|
|
|
// Lambda-parameter scopes: receivers that name a lambda param are
|
|
// locally bound and must not pick up an outer receiver_type.
|
|
lambdaScopes := collectKotlinLambdaScopes(root, src)
|
|
|
|
// Resolve calls against funcRanges + tenv.
|
|
funcRanges := buildFuncRanges(result)
|
|
|
|
// Variable / property annotations (`val x: T` / `var x: T`) reference
|
|
// type T. Attribute each EdgeTypedAs to the enclosing function (so a
|
|
// find_usages of T lands on the function that uses it), falling back to
|
|
// the file node for top-level / class-body properties outside any
|
|
// function range. Parameter and return-type edges are emitted inline by
|
|
// emitKotlinFunctionShape; this post-pass covers the body/property case.
|
|
for _, tu := range typeUses {
|
|
ownerID := findEnclosingFunc(funcRanges, tu.line)
|
|
if ownerID == "" {
|
|
ownerID = fileID
|
|
}
|
|
emitKotlinTypeUseEdges(ownerID, tu.typeText, filePath, tu.line, result)
|
|
}
|
|
|
|
for _, c := range calls {
|
|
// A bare Capitalized callee (`OkHttpClient()`) is a constructor call,
|
|
// not a function call — Kotlin has no `new`. emitKotlinReferenceForms
|
|
// owns it as an EdgeInstantiates to the type; emitting an EdgeCalls to
|
|
// `unresolved::*.OkHttpClient` here would be a dead duplicate (it never
|
|
// lands on a method) and would let the type's construction sites hide
|
|
// from a find_usages of the type. Skip it.
|
|
if !c.isMember && isKotlinTypeName(c.name) {
|
|
continue
|
|
}
|
|
callerID := findEnclosingFunc(funcRanges, c.line)
|
|
if callerID == "" {
|
|
continue
|
|
}
|
|
edge := &graph.Edge{
|
|
From: callerID, To: "unresolved::*." + c.name,
|
|
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
|
}
|
|
if c.isMember && !kotlinReceiverIsLambdaParam(lambdaScopes, c.receiver, c.line) {
|
|
switch {
|
|
case tenv[c.receiver] != "":
|
|
edge.Meta = map[string]any{"receiver_type": tenv[c.receiver]}
|
|
case typeNames[c.receiver]:
|
|
// Static dispatch: `Foo.create()` / `Foo.Factory.thing()`.
|
|
edge.Meta = map[string]any{"receiver_type": c.receiver}
|
|
case strings.Contains(c.receiver, ".") || strings.Contains(c.receiver, "("):
|
|
stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, tenv, result))
|
|
}
|
|
}
|
|
result.Edges = append(result.Edges, edge)
|
|
}
|
|
|
|
// Reference forms the type-position passes don't cover: instantiation
|
|
// (`OkHttpClient()`), casts / type-tests (`as`/`as?`/`is`/`!is`), static /
|
|
// companion / nested-type access (`OkHttpClient.Builder`), and supertype /
|
|
// interface inheritance (`class X : Bar(), Iface`). Each lands on the
|
|
// referenced type and carries Meta["ref_context"] so find_usages surfaces
|
|
// every mention of a type, not just its declaration sites.
|
|
emitKotlinReferenceForms(root, src, filePath, fileID, funcRanges, result)
|
|
|
|
// Expo Modules native DSL (Name/Function/AsyncFunction) → synthetic
|
|
// JS-callable method nodes for the Expo bridge synthesizer.
|
|
emitExpoModuleNodes(src, filePath, "kotlin", fileID, result, seen)
|
|
|
|
// React Native native event emits (getJSModule(...).emit / sendEvent helper)
|
|
// pair with the JS addListener handler on the same rn_native_event topic.
|
|
mineRNJVMEmits(src, func(line int) string {
|
|
return findEnclosingFunc(funcRanges, line)
|
|
}, filePath, "kotlin", result)
|
|
|
|
stampScopePkg(result, kotlinPackageName(root, src))
|
|
captureValueRefCandidates(result, root, filePath, src)
|
|
captureFnValueCandidates(result, root, filePath, src)
|
|
return result, nil
|
|
}
|
|
|
|
// kotlinPackageName returns the dotted name from the file's `package` header,
|
|
// or "". It scans by content prefix so it is robust to the grammar's exact node
|
|
// name for the header.
|
|
func kotlinPackageName(root *sitter.Node, src []byte) string {
|
|
for i, _nc := 0, int(root.ChildCount()); i < _nc; i++ {
|
|
c := root.Child(i)
|
|
txt := strings.TrimSpace(c.Content(src))
|
|
if !strings.HasPrefix(txt, "package") {
|
|
continue
|
|
}
|
|
rest := strings.TrimSpace(strings.TrimPrefix(txt, "package"))
|
|
if cut := strings.IndexAny(rest, "\n\r;"); cut >= 0 {
|
|
rest = rest[:cut]
|
|
}
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- Per-match emit helpers -----------------------------------------
|
|
|
|
// emitClassOrInterface inspects the children of a class_declaration to
|
|
// classify it as a Kotlin class (KindType), interface (KindInterface),
|
|
// or enum (KindType + Meta["kind"]="enum"). For enums it also walks
|
|
// the enum_class_body to emit one variable node per enum_entry. This
|
|
// replaces the legacy extractClassesAndInterfaces walkNodes pass.
|
|
func (e *KotlinExtractor) emitClassOrInterface(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
|
|
def := m.Captures["class.def"]
|
|
name := m.Captures["class.name"].Text
|
|
id := filePath + "::" + name
|
|
if seen[id] {
|
|
return
|
|
}
|
|
|
|
isInterface := false
|
|
var enumBody *sitter.Node
|
|
if def.Node != nil {
|
|
for i, _nc := 0, int(def.Node.ChildCount()); i < _nc; i++ {
|
|
child := def.Node.Child(i)
|
|
switch child.Type() {
|
|
case "interface":
|
|
isInterface = true
|
|
case "enum_class_body":
|
|
enumBody = child
|
|
}
|
|
}
|
|
}
|
|
|
|
kind := graph.KindType
|
|
meta := map[string]any{"visibility": kotlinVisibility(def.Node, src)}
|
|
if _, kmpRole := kotlinModifierFlags(def.Node, src); kmpRole != "" {
|
|
meta["kmp_role"] = kmpRole
|
|
}
|
|
switch {
|
|
case isInterface:
|
|
kind = graph.KindInterface
|
|
meta["type_flavor"] = "interface"
|
|
case enumBody != nil:
|
|
meta["kind"] = "enum"
|
|
meta["type_flavor"] = "enum"
|
|
default:
|
|
meta["type_flavor"] = "class"
|
|
}
|
|
if doc := ExtractDocAbove(src, def.StartLine, DocLangBlockStar); doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
|
|
seen[id] = true
|
|
startLine := def.StartLine + 1
|
|
endLine := def.EndLine + 1
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: kind, Name: name,
|
|
FilePath: filePath, StartLine: startLine, EndLine: endLine,
|
|
Language: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine,
|
|
})
|
|
emitKotlinAnnotationEdges(kotlinCollectAnnotations(def.Node, src), id, filePath, result, annotationSeen)
|
|
emitKotlinGenericParamNodes(id, def.Node, src, filePath, startLine, result)
|
|
|
|
// A `data class` auto-generates one `componentN()` accessor per
|
|
// primary-constructor property (in order) plus a `copy()` returning the
|
|
// class — the compiler really emits these, so model them as members so
|
|
// destructuring (`val (x, y) = p`) and `p.copy()` chains resolve.
|
|
emitKotlinDataClassMembers(def.Node, name, id, filePath, startLine, endLine, src, result, seen)
|
|
|
|
if enumBody == nil {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(enumBody.ChildCount()); i < _nc; i++ {
|
|
entry := enumBody.Child(i)
|
|
if entry == nil || entry.Type() != "enum_entry" {
|
|
continue
|
|
}
|
|
var entryName string
|
|
for j, _nc := 0, int(entry.ChildCount()); j < _nc; j++ {
|
|
ch := entry.Child(j)
|
|
if ch != nil && ch.Type() == "simple_identifier" {
|
|
entryName = ch.Content(src)
|
|
break
|
|
}
|
|
}
|
|
if entryName == "" {
|
|
continue
|
|
}
|
|
entryID := id + "." + entryName
|
|
entryStart := int(entry.StartPoint().Row) + 1
|
|
entryEnd := int(entry.EndPoint().Row) + 1
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: entryID, Kind: graph.KindEnumMember, Name: entryName,
|
|
FilePath: filePath, StartLine: entryStart, EndLine: entryEnd,
|
|
Language: "kotlin",
|
|
Meta: map[string]any{"receiver": name, "enum": id},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: entryID, To: id, Kind: graph.EdgeMemberOf,
|
|
FilePath: filePath, Line: entryStart,
|
|
})
|
|
}
|
|
}
|
|
|
|
// kotlinIsDataClass reports whether a class_declaration carries the `data`
|
|
// modifier (`modifiers` → `class_modifier` token "data").
|
|
func kotlinIsDataClass(decl *sitter.Node, src []byte) bool {
|
|
if decl == nil {
|
|
return false
|
|
}
|
|
mods := firstChildOfType(decl, "modifiers")
|
|
if mods == nil {
|
|
return false
|
|
}
|
|
for c := range mods.NamedChildren() {
|
|
if c.Type() == "class_modifier" && strings.TrimSpace(c.Content(src)) == "data" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// kotlinDataClassProperty is one primary-constructor `val`/`var` property of
|
|
// a data class, in declaration order. `typ` is the normalized element type
|
|
// (empty for a primitive / unresolvable annotation).
|
|
type kotlinDataClassProperty struct {
|
|
name string
|
|
typ string
|
|
}
|
|
|
|
// kotlinPrimaryCtorProperties returns the primary-constructor property
|
|
// parameters of a class declaration, in order. Only `val`/`var` parameters
|
|
// (those carrying a binding_pattern_kind child) are properties; a plain
|
|
// constructor parameter is a local and is skipped — mirroring kotlinFields
|
|
// in the type engine.
|
|
func kotlinPrimaryCtorProperties(decl *sitter.Node, src []byte) []kotlinDataClassProperty {
|
|
pc := firstChildOfType(decl, "primary_constructor")
|
|
if pc == nil {
|
|
return nil
|
|
}
|
|
var out []kotlinDataClassProperty
|
|
for c := range pc.NamedChildren() {
|
|
if c.Type() != "class_parameter" {
|
|
continue
|
|
}
|
|
if firstChildOfType(c, "binding_pattern_kind") == nil {
|
|
continue
|
|
}
|
|
ident := firstChildOfType(c, "simple_identifier")
|
|
if ident == nil {
|
|
continue
|
|
}
|
|
name := ident.Content(src)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
typ := ""
|
|
if ut := firstChildOfType(c, "user_type"); ut != nil {
|
|
typ = normalizeKotlinTypeName(ut.Content(src))
|
|
}
|
|
out = append(out, kotlinDataClassProperty{name: name, typ: typ})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// kotlinDeclaredMethodNames returns the set of method names a class declares
|
|
// in its own body, so synthesis can yield to a user-written `copy` /
|
|
// `componentN` (an explicitly declared member must win over the synthetic
|
|
// one; emitting both would make the member lookup ambiguous).
|
|
func kotlinDeclaredMethodNames(decl *sitter.Node, src []byte) map[string]bool {
|
|
out := make(map[string]bool)
|
|
body := firstChildOfType(decl, "class_body")
|
|
if body == nil {
|
|
return out
|
|
}
|
|
for c := range body.NamedChildren() {
|
|
if c.Type() != "function_declaration" {
|
|
continue
|
|
}
|
|
if ident := firstChildOfType(c, "simple_identifier"); ident != nil {
|
|
out[ident.Content(src)] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// emitKotlinDataClassMembers synthesizes the compiler-generated members of a
|
|
// Kotlin `data class`: `component1()..componentN()` (componentI returns the
|
|
// I-th primary-constructor property's type) and `copy()` (returns the class).
|
|
// They are emitted as KindMethod members (EdgeMemberOf to the class, like the
|
|
// enum-entry / companion-alias members) so the type engine's member lookup
|
|
// resolves `p.componentN()` / `p.copy()` with no engine change, and so they
|
|
// surface in find_usages / get_callers. Each is marked
|
|
// Meta["synthetic"]="data_class" and Meta["generated"]=true — the latter
|
|
// keeps them out of dead-code reporting (they have no hand-written call site
|
|
// by construction). An explicitly declared member of the same name wins:
|
|
// synthesis is skipped for any name the class body already declares.
|
|
func emitKotlinDataClassMembers(decl *sitter.Node, className, ownerID, filePath string, startLine, endLine int, src []byte, result *parser.ExtractionResult, seen map[string]bool) {
|
|
if !kotlinIsDataClass(decl, src) {
|
|
return
|
|
}
|
|
declared := kotlinDeclaredMethodNames(decl, src)
|
|
props := kotlinPrimaryCtorProperties(decl, src)
|
|
|
|
emit := func(method, returnType string) {
|
|
if declared[method] {
|
|
return
|
|
}
|
|
memberID := filePath + "::" + className + "." + method
|
|
if seen[memberID] {
|
|
return
|
|
}
|
|
seen[memberID] = true
|
|
meta := map[string]any{
|
|
"receiver": className,
|
|
"visibility": "public",
|
|
"synthetic": "data_class",
|
|
"generated": true,
|
|
}
|
|
if returnType != "" {
|
|
meta["return_type"] = returnType
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: memberID, Kind: graph.KindMethod, Name: method,
|
|
FilePath: filePath, StartLine: startLine, EndLine: endLine,
|
|
Language: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: memberID, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine,
|
|
})
|
|
}
|
|
|
|
for i, p := range props {
|
|
emit(fmt.Sprintf("component%d", i+1), p.typ)
|
|
}
|
|
// `copy` returns the data class itself, so `p.copy().componentN()` chains.
|
|
emit("copy", className)
|
|
}
|
|
|
|
func (e *KotlinExtractor) emitObject(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
|
|
name := m.Captures["obj.name"].Text
|
|
def := m.Captures["obj.def"]
|
|
id := filePath + "::" + name
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
meta := map[string]any{"visibility": kotlinVisibility(def.Node, src)}
|
|
if doc := ExtractDocAbove(src, def.StartLine, DocLangBlockStar); doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
meta["type_flavor"] = "object"
|
|
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: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
emitKotlinAnnotationEdges(kotlinCollectAnnotations(def.Node, src), id, filePath, result, annotationSeen)
|
|
}
|
|
|
|
// emitFunction classifies each function_declaration by its enclosing
|
|
// container — a direct child of class_body whose grandparent is a
|
|
// class_declaration emits as a class method; class_body of an
|
|
// object_declaration emits as an object method; anything else is a
|
|
// top-level (free) function. This mirrors the legacy nested
|
|
// kotlinQClassMethod / kotlinQObjectMethod pair plus the
|
|
// kotlinQFunction fallback's per-line dedupe.
|
|
func (e *KotlinExtractor) 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
|
|
endLine1 := def.EndLine + 1
|
|
|
|
doc := ExtractDocAbove(src, def.StartLine, DocLangBlockStar)
|
|
visibility := kotlinVisibility(def.Node, src)
|
|
isAsync, kmpRole := kotlinModifierFlags(def.Node, src)
|
|
|
|
ownerInfo := kotlinResolveMemberOwner(def.Node, src)
|
|
owner, ownerKind := ownerInfo.name, ownerInfo.kind
|
|
if ownerKind == "" {
|
|
// Extension function: `fun Receiver.name()` attributes to the receiver
|
|
// type even though it is declared at the top level.
|
|
if recv := kotlinExtensionReceiver(def.Node, src); recv != "" {
|
|
owner, ownerKind = recv, "extension"
|
|
}
|
|
}
|
|
if ownerKind != "" {
|
|
id := filePath + "::" + owner + "." + name
|
|
if seen[id] {
|
|
id = filePath + "::" + owner + "." + name + "_L" + fmt.Sprint(startLine1)
|
|
}
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
meta := map[string]any{
|
|
"receiver": owner,
|
|
"visibility": visibility,
|
|
}
|
|
kotlinStampModMeta(meta, isAsync, kmpRole)
|
|
// An extension function (`fun Foo.ext()`) is declared at file scope,
|
|
// not inside Foo's body, so it is not a structural member of Foo and
|
|
// its synthetic member_of edge points at a same-file node that, when
|
|
// Foo lives in another file, does not exist. Mark it so the type
|
|
// engine can resolve `recv.ext()` against the real receiver type by
|
|
// name, as a fallback after a real member lookup misses.
|
|
if ownerKind == "extension" {
|
|
meta["extension_receiver"] = owner
|
|
}
|
|
// Companion-object members dispatch on the TYPE (`Foo.create()`),
|
|
// so the method is attributed to the enclosing class and flagged
|
|
// static. Without this an agent can't see that the companion's
|
|
// `create` is reachable via the class name.
|
|
if ownerInfo.companion {
|
|
meta["static"] = true
|
|
meta["companion"] = true
|
|
if ownerInfo.companionName != "" {
|
|
meta["companion_name"] = ownerInfo.companionName
|
|
}
|
|
}
|
|
if doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
if rt := extractKotlinReturnType(def.Node, src); rt != "" {
|
|
meta["return_type"] = rt
|
|
}
|
|
kotlinMarkComposable(meta, def.Node, src)
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindMethod, Name: name,
|
|
FilePath: filePath, StartLine: startLine1, EndLine: endLine1,
|
|
Language: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
|
|
})
|
|
ownerID := filePath + "::" + owner
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1,
|
|
})
|
|
emitKotlinAnnotationEdges(kotlinCollectAnnotations(def.Node, src), id, filePath, result, annotationSeen)
|
|
emitKotlinFunctionShape(id, def.Node, src, filePath, startLine1, result)
|
|
if body := kotlinFunctionBody(def.Node); body != nil {
|
|
emitKotlinAsyncSpawns(id, body, src, filePath, result)
|
|
}
|
|
|
|
// Named companion: `companion object Factory { fun thing() }` is
|
|
// callable as both `Foo.thing()` (handled above via the enclosing
|
|
// owner) and `Foo.Factory.thing()`. Emit an alias method whose
|
|
// receiver is `Foo.Factory` so the qualified call resolves too.
|
|
if ownerInfo.companion && ownerInfo.companionName != "" {
|
|
aliasRecv := owner + "." + ownerInfo.companionName
|
|
aliasID := filePath + "::" + aliasRecv + "." + name
|
|
if !seen[aliasID] {
|
|
seen[aliasID] = true
|
|
aliasMeta := map[string]any{
|
|
"receiver": aliasRecv,
|
|
"visibility": visibility,
|
|
"static": true,
|
|
"companion": true,
|
|
"companion_name": ownerInfo.companionName,
|
|
"companion_alias": true,
|
|
}
|
|
if doc != "" {
|
|
aliasMeta["doc"] = doc
|
|
}
|
|
if rt := extractKotlinReturnType(def.Node, src); rt != "" {
|
|
aliasMeta["return_type"] = rt
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: aliasID, Kind: graph.KindMethod, Name: name,
|
|
FilePath: filePath, StartLine: startLine1, EndLine: endLine1,
|
|
Language: "kotlin",
|
|
Meta: aliasMeta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: aliasID, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1,
|
|
})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Top-level (or nested-in-fn) — emit as KindFunction.
|
|
id := filePath + "::" + name
|
|
if seen[id] {
|
|
id = filePath + "::" + name + "_L" + fmt.Sprint(startLine1)
|
|
}
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
meta := map[string]any{"visibility": visibility}
|
|
kotlinStampModMeta(meta, isAsync, kmpRole)
|
|
if doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
kotlinMarkComposable(meta, def.Node, src)
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindFunction, Name: name,
|
|
FilePath: filePath, StartLine: startLine1, EndLine: endLine1,
|
|
Language: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
|
|
})
|
|
emitKotlinAnnotationEdges(kotlinCollectAnnotations(def.Node, src), id, filePath, result, annotationSeen)
|
|
emitKotlinFunctionShape(id, def.Node, src, filePath, startLine1, result)
|
|
if body := kotlinFunctionBody(def.Node); body != nil {
|
|
emitKotlinAsyncSpawns(id, body, src, filePath, result)
|
|
}
|
|
}
|
|
|
|
// kotlinCollectAnnotations walks a Kotlin declaration's modifiers
|
|
// child for annotation nodes and returns the bare annotation names
|
|
// plus their args (typically `(...)` text after the annotation name).
|
|
// kotlinMarkComposable stamps the Jetpack Compose component marker onto
|
|
// a function's meta map when the function carries a @Composable
|
|
// annotation. Zero false-positive risk — the annotation is the signal.
|
|
func kotlinMarkComposable(meta map[string]any, decl *sitter.Node, src []byte) {
|
|
for _, a := range kotlinCollectAnnotations(decl, src) {
|
|
if a.name == "Composable" {
|
|
meta["ui_component"] = "compose"
|
|
meta["component_kind"] = "function"
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func kotlinCollectAnnotations(decl *sitter.Node, src []byte) []javaAnnotation {
|
|
if decl == nil {
|
|
return nil
|
|
}
|
|
var out []javaAnnotation
|
|
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
|
|
c := decl.Child(i)
|
|
if c == nil || c.Type() != "modifiers" {
|
|
continue
|
|
}
|
|
for j, _nc := 0, int(c.ChildCount()); j < _nc; j++ {
|
|
ann := c.Child(j)
|
|
if ann == nil {
|
|
continue
|
|
}
|
|
// Kotlin grammar exposes annotations as `annotation`
|
|
// children containing a `user_type` (the name) and an
|
|
// optional `value_arguments` (the parens).
|
|
if ann.Type() != "annotation" {
|
|
continue
|
|
}
|
|
var name, args string
|
|
line := int(ann.StartPoint().Row) + 1
|
|
for k, _nc := 0, int(ann.ChildCount()); k < _nc; k++ {
|
|
inner := ann.Child(k)
|
|
if inner == nil {
|
|
continue
|
|
}
|
|
switch inner.Type() {
|
|
case "user_type", "constructor_invocation":
|
|
if name == "" {
|
|
name = strings.TrimSpace(inner.Content(src))
|
|
}
|
|
case "value_arguments":
|
|
txt := inner.Content(src)
|
|
if len(txt) >= 2 && txt[0] == '(' && txt[len(txt)-1] == ')' {
|
|
txt = txt[1 : len(txt)-1]
|
|
}
|
|
args = txt
|
|
}
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
// strip a "()" suffix that the grammar may wrap into the user_type.
|
|
if idx := strings.Index(name, "("); idx >= 0 {
|
|
if args == "" {
|
|
args = strings.TrimSuffix(name[idx+1:], ")")
|
|
}
|
|
name = name[:idx]
|
|
}
|
|
out = append(out, javaAnnotation{name: strings.TrimSpace(name), args: args, line: line})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func emitKotlinAnnotationEdges(anns []javaAnnotation, fromID, filePath string, result *parser.ExtractionResult, seen map[string]bool) {
|
|
for _, a := range anns {
|
|
if a.name == "" {
|
|
continue
|
|
}
|
|
EmitAnnotationEdge(fromID, "kotlin", a.name, a.args, filePath, a.line, result, seen)
|
|
}
|
|
}
|
|
|
|
// kotlinVisibility scans a declaration's modifiers child for a
|
|
// visibility modifier. Kotlin's default visibility is "public" when
|
|
// none is declared (different from Java's package-private default).
|
|
func kotlinVisibility(decl *sitter.Node, src []byte) string {
|
|
if decl == nil {
|
|
return VisibilityPublic
|
|
}
|
|
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
|
|
c := decl.Child(i)
|
|
if c == nil || c.Type() != "modifiers" {
|
|
continue
|
|
}
|
|
for j, _nc := 0, int(c.ChildCount()); j < _nc; j++ {
|
|
tok := c.Child(j)
|
|
if tok == nil {
|
|
continue
|
|
}
|
|
if tok.Type() != "visibility_modifier" {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(tok.Content(src)) {
|
|
case "public", "open":
|
|
return VisibilityPublic
|
|
case "private":
|
|
return VisibilityPrivate
|
|
case "protected":
|
|
return VisibilityProtected
|
|
case "internal":
|
|
return VisibilityInternal
|
|
}
|
|
}
|
|
}
|
|
return VisibilityPublic
|
|
}
|
|
|
|
// kotlinModifierFlags scans a declaration's modifiers for the suspend
|
|
// (coroutine) and expect/actual (Kotlin Multiplatform) keywords.
|
|
func kotlinModifierFlags(decl *sitter.Node, src []byte) (isAsync bool, kmpRole string) {
|
|
if decl == nil {
|
|
return false, ""
|
|
}
|
|
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
|
|
c := decl.Child(i)
|
|
if c == nil || c.Type() != "modifiers" {
|
|
continue
|
|
}
|
|
for j, _nc := 0, int(c.ChildCount()); j < _nc; j++ {
|
|
tok := c.Child(j)
|
|
if tok == nil {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(tok.Content(src)) {
|
|
case "suspend":
|
|
isAsync = true
|
|
case "expect":
|
|
kmpRole = "expect"
|
|
case "actual":
|
|
kmpRole = "actual"
|
|
}
|
|
}
|
|
}
|
|
return isAsync, kmpRole
|
|
}
|
|
|
|
// kotlinStampModMeta records the async and Kotlin-Multiplatform role flags on a
|
|
// declaration node's Meta.
|
|
func kotlinStampModMeta(meta map[string]any, isAsync bool, kmpRole string) {
|
|
if isAsync {
|
|
meta["is_async"] = true
|
|
}
|
|
if kmpRole != "" {
|
|
meta["kmp_role"] = kmpRole
|
|
}
|
|
}
|
|
|
|
// kotlinExtensionReceiver returns the receiver type name of an extension
|
|
// function (`fun Foo.bar()` -> "Foo"), or "" when fn is not an extension.
|
|
// The receiver is the `receiver_type` child that precedes the function name
|
|
// in the function_declaration; a plain `fun free()` has no such child and is
|
|
// not an extension. The bare type name is the last `type_identifier` under
|
|
// the (possibly nullable) `user_type`, which drops a nullable marker
|
|
// (`Foo?` -> "Foo"), generic arguments (`List<T>` -> "List", whose `T` hangs
|
|
// off a nested type_arguments node, not a direct child), and a package
|
|
// qualifier (`com.x.Foo` -> "Foo", whose earlier segments are the leading
|
|
// type_identifier children).
|
|
func kotlinExtensionReceiver(fn *sitter.Node, src []byte) string {
|
|
if fn == nil || fn.Type() != "function_declaration" {
|
|
return ""
|
|
}
|
|
rt := firstChildOfType(fn, "receiver_type")
|
|
if rt == nil {
|
|
return ""
|
|
}
|
|
ut := firstChildOfType(rt, "user_type")
|
|
if ut == nil {
|
|
if nt := firstChildOfType(rt, "nullable_type"); nt != nil {
|
|
ut = firstChildOfType(nt, "user_type")
|
|
}
|
|
}
|
|
if ut == nil {
|
|
return ""
|
|
}
|
|
name := ""
|
|
for i, _nc := 0, int(ut.ChildCount()); i < _nc; i++ {
|
|
if c := ut.Child(i); c != nil && c.Type() == "type_identifier" {
|
|
name = c.Content(src)
|
|
}
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|
|
|
|
func (e *KotlinExtractor) emitImport(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) {
|
|
path := m.Captures["import.path"]
|
|
importPath := strings.ReplaceAll(path.Text, ".", "/")
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: "unresolved::import::" + importPath,
|
|
Kind: graph.EdgeImports, FilePath: filePath, Line: path.StartLine + 1,
|
|
})
|
|
}
|
|
|
|
// --- Helpers --------------------------------------------------------
|
|
|
|
// kotlinMemberOwner describes the container a declaration belongs to.
|
|
// For a plain class/object method, name is the type name and
|
|
// companionName is empty. For a companion-object member, name is the
|
|
// ENCLOSING class (so `Foo.create()` resolves to the companion's
|
|
// create) and companionName carries the companion's declared name —
|
|
// empty for an anonymous `companion object`, the identifier for a named
|
|
// one like `companion object Factory`.
|
|
type kotlinMemberOwner struct {
|
|
name string // enclosing type/object name
|
|
kind string // "class_declaration" | "object_declaration"
|
|
companion bool // member lives in a companion object
|
|
companionName string // declared companion name, "" if anonymous
|
|
}
|
|
|
|
// kotlinResolveMemberOwner walks up from a declaration to find its
|
|
// container. It transparently sees through a companion_object wrapper:
|
|
// `class Foo { companion object Factory { fun create() {} } }` resolves
|
|
// the owner of create to Foo while recording companion=true and
|
|
// companionName="Factory". This is what makes `Foo.create()` (static
|
|
// dispatch on the type) discoverable.
|
|
func kotlinResolveMemberOwner(fn *sitter.Node, src []byte) kotlinMemberOwner {
|
|
if fn == nil {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
parent := fn.Parent()
|
|
if parent == nil || parent.Type() != "class_body" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
grand := parent.Parent()
|
|
if grand == nil {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
|
|
// Companion-object member: `class Foo { companion object [Name] { ... } }`.
|
|
// The grandparent is the companion_object; its enclosing class is two
|
|
// more levels up (class_body → class_declaration / object_declaration).
|
|
if grand.Type() == "companion_object" {
|
|
companionName := kotlinTypeIdentifierChild(grand, src)
|
|
outerBody := grand.Parent()
|
|
if outerBody == nil || outerBody.Type() != "class_body" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
outer := outerBody.Parent()
|
|
if outer == nil {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
otype := outer.Type()
|
|
if otype != "class_declaration" && otype != "object_declaration" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
name := kotlinTypeIdentifierChild(outer, src)
|
|
if name == "" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
return kotlinMemberOwner{
|
|
name: name,
|
|
kind: otype,
|
|
companion: true,
|
|
companionName: companionName,
|
|
}
|
|
}
|
|
|
|
gtype := grand.Type()
|
|
if gtype != "class_declaration" && gtype != "object_declaration" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
name := kotlinTypeIdentifierChild(grand, src)
|
|
if name == "" {
|
|
return kotlinMemberOwner{}
|
|
}
|
|
return kotlinMemberOwner{name: name, kind: gtype}
|
|
}
|
|
|
|
// kotlinTypeIdentifierChild returns the first type_identifier child of
|
|
// node (the declared name of a class/object/companion), or "" when the
|
|
// node is anonymous.
|
|
func kotlinTypeIdentifierChild(node *sitter.Node, src []byte) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
|
|
ch := node.Child(i)
|
|
if ch != nil && ch.Type() == "type_identifier" {
|
|
return ch.Content(src)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// kotlinCollectTypeNames returns the set of receiver strings that name a
|
|
// type for the purpose of static (companion-object) dispatch: every
|
|
// class / object / interface name, plus the `Type.Companion` aliases
|
|
// emitted for named companion objects (so `Bar.Factory.thing()` lands on
|
|
// the alias method's receiver). The companion alias receivers are read
|
|
// back from the alias method nodes' Meta["receiver"].
|
|
func kotlinCollectTypeNames(result *parser.ExtractionResult) map[string]bool {
|
|
names := map[string]bool{}
|
|
for _, n := range result.Nodes {
|
|
switch n.Kind {
|
|
case graph.KindType, graph.KindInterface:
|
|
if n.Name != "" {
|
|
names[n.Name] = true
|
|
}
|
|
case graph.KindMethod:
|
|
if n.Meta == nil {
|
|
continue
|
|
}
|
|
if alias, _ := n.Meta["companion_alias"].(bool); !alias {
|
|
continue
|
|
}
|
|
if recv, _ := n.Meta["receiver"].(string); recv != "" {
|
|
names[recv] = true
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// emitKotlinCompanionProperty emits one member node for a property
|
|
// declared inside a companion object, attributed to the enclosing type
|
|
// so `Foo.NAME` is discoverable. A `const`-modified property is a
|
|
// KindConstant; a plain `val`/`var` is a KindField. Both carry
|
|
// Meta["receiver"] = <enclosing type> and Meta["static"] = true. For a
|
|
// named companion an alias member (`Type.Companion.NAME`) is added too.
|
|
func emitKotlinCompanionProperty(p kotlinDeferredProperty, owner kotlinMemberOwner, filePath string, src []byte, result *parser.ExtractionResult, seen map[string]bool) {
|
|
kind := graph.KindField
|
|
if kotlinPropertyIsConst(p.defNode, src) {
|
|
kind = graph.KindConstant
|
|
}
|
|
emit := func(recv string) {
|
|
id := filePath + "::" + recv + "." + p.name
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
meta := map[string]any{
|
|
"receiver": recv,
|
|
"static": true,
|
|
"companion": true,
|
|
}
|
|
if owner.companionName != "" {
|
|
meta["companion_name"] = owner.companionName
|
|
}
|
|
ownerID := filePath + "::" + owner.name
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: kind, Name: p.name,
|
|
FilePath: filePath, StartLine: p.line, EndLine: p.endLine,
|
|
Language: "kotlin",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: p.line,
|
|
})
|
|
}
|
|
emit(owner.name)
|
|
if owner.companionName != "" {
|
|
emit(owner.name + "." + owner.companionName)
|
|
}
|
|
}
|
|
|
|
// kotlinPropertyIsConst reports whether a property_declaration carries a
|
|
// `const` modifier.
|
|
func kotlinPropertyIsConst(decl *sitter.Node, src []byte) bool {
|
|
if decl == nil {
|
|
return false
|
|
}
|
|
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
|
|
c := decl.Child(i)
|
|
if c == nil || c.Type() != "modifiers" {
|
|
continue
|
|
}
|
|
for j, _nc := 0, int(c.ChildCount()); j < _nc; j++ {
|
|
tok := c.Child(j)
|
|
if tok == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(tok.Content(src)) == "const" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// collectKotlinLambdaScopes walks the tree for lambda_literal nodes and
|
|
// records, for each, the parameter names it binds plus the line span of
|
|
// its body. Explicit params come from the `lambda_parameters` child
|
|
// (`{ a, b -> ... }`); a lambda with no declared params implicitly binds
|
|
// `it` (`list.forEach { println(it) }`). These scopes let the call
|
|
// resolver recognise that a member call whose receiver is a lambda
|
|
// parameter is locally bound, so it is not mis-resolved against the
|
|
// outer type environment.
|
|
func collectKotlinLambdaScopes(root *sitter.Node, src []byte) []kotlinLambdaScope {
|
|
var scopes []kotlinLambdaScope
|
|
walkNodes(root, func(n *sitter.Node) {
|
|
if n == nil || n.Type() != "lambda_literal" {
|
|
return
|
|
}
|
|
params := map[string]bool{}
|
|
hasExplicit := false
|
|
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
|
|
ch := n.Child(i)
|
|
if ch == nil || ch.Type() != "lambda_parameters" {
|
|
continue
|
|
}
|
|
hasExplicit = true
|
|
// lambda_parameters → variable_declaration → simple_identifier,
|
|
// possibly several (destructuring / multi-param).
|
|
walkNodes(ch, func(p *sitter.Node) {
|
|
if p != nil && p.Type() == "simple_identifier" {
|
|
params[p.Content(src)] = true
|
|
}
|
|
})
|
|
}
|
|
if !hasExplicit {
|
|
// No declared params: the implicit single parameter `it`.
|
|
params["it"] = true
|
|
}
|
|
if len(params) == 0 {
|
|
return
|
|
}
|
|
scopes = append(scopes, kotlinLambdaScope{
|
|
params: params,
|
|
startLine: int(n.StartPoint().Row) + 1,
|
|
endLine: int(n.EndPoint().Row) + 1,
|
|
})
|
|
})
|
|
return scopes
|
|
}
|
|
|
|
// kotlinReceiverIsLambdaParam reports whether the head identifier of a
|
|
// receiver expression names a lambda parameter that is in scope at the
|
|
// call's line. The head is the first dotted segment, so both `it` and
|
|
// `item` (in `item.x`) are caught.
|
|
func kotlinReceiverIsLambdaParam(scopes []kotlinLambdaScope, receiver string, line int) bool {
|
|
head := receiver
|
|
if idx := strings.IndexByte(head, '.'); idx >= 0 {
|
|
head = head[:idx]
|
|
}
|
|
if idx := strings.IndexByte(head, '('); idx >= 0 {
|
|
head = head[:idx]
|
|
}
|
|
head = strings.TrimSpace(head)
|
|
if head == "" {
|
|
return false
|
|
}
|
|
for _, s := range scopes {
|
|
if line < s.startLine || line > s.endLine {
|
|
continue
|
|
}
|
|
if s.params[head] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeKotlinTypeName strips generics and nullable markers from a Kotlin type name.
|
|
func normalizeKotlinTypeName(t string) string {
|
|
t = strings.TrimSpace(t)
|
|
// Remove nullable suffix.
|
|
t = strings.TrimSuffix(t, "?")
|
|
// Remove generics.
|
|
if idx := strings.Index(t, "<"); idx > 0 {
|
|
t = t[:idx]
|
|
}
|
|
// Skip Kotlin primitives.
|
|
switch t {
|
|
case "Int", "Long", "Short", "Byte", "Float", "Double", "Boolean",
|
|
"Char", "String", "Unit", "Any", "Nothing":
|
|
return ""
|
|
}
|
|
if t == "" || (t[0] >= 'a' && t[0] <= 'z') {
|
|
return ""
|
|
}
|
|
return t
|
|
}
|
|
|
|
// extractKotlinReturnType walks a function_declaration node to find the return type annotation.
|
|
// Kotlin functions have optional `: ReturnType` after the parameter list.
|
|
func extractKotlinReturnType(node *sitter.Node, src []byte) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
// Look for user_type or nullable_type child after the function_value_parameters.
|
|
pastParams := false
|
|
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
|
|
child := node.Child(i)
|
|
if child.Type() == "function_value_parameters" {
|
|
pastParams = true
|
|
continue
|
|
}
|
|
if pastParams {
|
|
switch child.Type() {
|
|
case "user_type", "nullable_type":
|
|
rawType := string(src[child.StartByte():child.EndByte()])
|
|
if rt := normalizeKotlinTypeName(rawType); rt != "" {
|
|
return rt
|
|
}
|
|
case "function_body":
|
|
// Stop looking once we hit the body.
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// walkNodes does a depth-first walk of the tree-sitter node tree.
|
|
// Shared with other language extractors via package scope.
|
|
func walkNodes(node *sitter.Node, fn func(*sitter.Node)) {
|
|
fn(node)
|
|
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
|
|
walkNodes(node.Child(i), fn)
|
|
}
|
|
}
|