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
907 lines
31 KiB
Go
907 lines
31 KiB
Go
package languages
|
|
|
|
import (
|
|
"regexp"
|
|
"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/swift"
|
|
)
|
|
|
|
// qSwiftAll is a single tree-sitter query alternating over every
|
|
// pattern the Swift extractor needs. One tree walk per file replaces
|
|
// the 9 `parser.RunQuery` calls (plus the duplicated triple-query pass
|
|
// the legacy collectTypeBodyRanges performed). Capture names are
|
|
// disjoint across patterns so the dispatch in Extract can branch on
|
|
// which name is set. Method-vs-function classification is performed
|
|
// inline by tracking class/struct/enum line ranges as their match
|
|
// arrives — types come before their members in document order, so the
|
|
// range table is always complete by the time a function_declaration is
|
|
// dispatched.
|
|
const qSwiftAll = `
|
|
[
|
|
(class_declaration
|
|
name: (type_identifier) @class.name) @class.def
|
|
|
|
(class_declaration
|
|
name: (type_identifier) @enum.name
|
|
body: (enum_class_body) @enum.body) @enum.def
|
|
|
|
(protocol_declaration
|
|
name: (type_identifier) @proto.name) @proto.def
|
|
|
|
(protocol_function_declaration
|
|
name: (simple_identifier) @protomethod.name) @protomethod.def
|
|
|
|
(function_declaration
|
|
name: (simple_identifier) @func.name) @func.def
|
|
|
|
(property_declaration
|
|
(pattern (simple_identifier) @property.name)) @property.def
|
|
|
|
(import_declaration) @import.def
|
|
|
|
(call_expression
|
|
(simple_identifier) @call.name) @call.expr
|
|
|
|
(call_expression
|
|
(navigation_expression
|
|
(navigation_suffix (simple_identifier) @callm.name)) @callm.nav) @callm.expr
|
|
]
|
|
`
|
|
|
|
// SwiftExtractor extracts Swift source files into graph nodes and edges.
|
|
type SwiftExtractor struct {
|
|
lang *sitter.Language
|
|
qAll *parser.PreparedQuery
|
|
}
|
|
|
|
func NewSwiftExtractor() *SwiftExtractor {
|
|
lang := swift.GetLanguage()
|
|
return &SwiftExtractor{
|
|
lang: lang,
|
|
qAll: parser.MustPreparedQuery(qSwiftAll, lang),
|
|
}
|
|
}
|
|
|
|
func (e *SwiftExtractor) Language() string { return "swift" }
|
|
func (e *SwiftExtractor) Extensions() []string { return []string{".swift"} }
|
|
|
|
// --- Deferred match buffers ----------------------------------------
|
|
|
|
type swiftDeferredCall struct {
|
|
name string
|
|
line int
|
|
isMember bool
|
|
receiver string
|
|
}
|
|
|
|
type swiftTypeRange struct {
|
|
name string
|
|
startLine int // 0-based
|
|
endLine int // 0-based
|
|
objcMembers bool // class declared @objcMembers -- exposes all members to ObjC
|
|
}
|
|
|
|
func (e *SwiftExtractor) 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: "swift",
|
|
}
|
|
fileID := fileNode.ID
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
seen := make(map[string]bool)
|
|
annotationSeen := make(map[string]bool)
|
|
protoMethods := make(map[string][]string) // protocol name → declared method names
|
|
var typeRanges []swiftTypeRange
|
|
// Extensions aren't captured by qSwiftAll (their name is a user_type, not a
|
|
// type_identifier), so seed their ranges first; members inside an
|
|
// `extension Foo { ... }` then attribute to Foo like any other type member.
|
|
typeRanges = append(typeRanges, swiftExtensionRanges(src)...)
|
|
// Resilience net for parse errors: a tree-sitter error inside a type body
|
|
// (e.g. an unparseable `#if … && !canImport(...)`) can corrupt the enclosing
|
|
// class_declaration so the query never matches it — its members and every
|
|
// reference to the type would then strand on unresolved::Name. Seed the
|
|
// container ranges from a brace-matched text scan so members still attribute;
|
|
// gaps the query misses get a fallback container node after the match loop.
|
|
fallbackTypes := swiftFallbackTypeDecls(src)
|
|
for _, ft := range fallbackTypes {
|
|
typeRanges = append(typeRanges, swiftTypeRange{name: ft.name, startLine: ft.startLine, endLine: ft.endLine})
|
|
}
|
|
var calls []swiftDeferredCall
|
|
|
|
parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) {
|
|
switch {
|
|
|
|
case m.Captures["class.def"] != nil:
|
|
e.emitTypeContainer(m, "class", filePath, fileID, src, result, seen, annotationSeen, &typeRanges, nil)
|
|
|
|
case m.Captures["enum.def"] != nil:
|
|
// May fire on the same class_declaration as the prior
|
|
// class.def pattern; emitTypeContainer handles the seen
|
|
// dedupe and stamps Meta["kind"]="enum" on the existing
|
|
// node when it does. Walks the captured enum_class_body
|
|
// for case entries.
|
|
body := m.Captures["enum.body"]
|
|
var bodyNode *sitter.Node
|
|
if body != nil {
|
|
bodyNode = body.Node
|
|
}
|
|
e.emitTypeContainer(m, "enum", filePath, fileID, src, result, seen, annotationSeen, &typeRanges, bodyNode)
|
|
|
|
case m.Captures["proto.def"] != nil:
|
|
e.emitProtocol(m, filePath, fileID, src, result, seen, annotationSeen)
|
|
|
|
case m.Captures["protomethod.def"] != nil:
|
|
e.recordProtocolMethod(m, src, protoMethods)
|
|
|
|
case m.Captures["func.def"] != nil:
|
|
e.emitFunction(m, filePath, fileID, src, result, seen, annotationSeen, typeRanges)
|
|
|
|
case m.Captures["property.def"] != nil:
|
|
e.emitProperty(m, filePath, fileID, src, result, seen, annotationSeen, typeRanges)
|
|
|
|
case m.Captures["import.def"] != nil:
|
|
e.emitImport(m, filePath, fileID, result)
|
|
|
|
case m.Captures["call.expr"] != nil:
|
|
expr := m.Captures["call.expr"]
|
|
calls = append(calls, swiftDeferredCall{
|
|
name: m.Captures["call.name"].Text,
|
|
line: expr.StartLine + 1,
|
|
})
|
|
|
|
case m.Captures["callm.expr"] != nil:
|
|
expr := m.Captures["callm.expr"]
|
|
recv := ""
|
|
if nav := m.Captures["callm.nav"]; nav != nil && nav.Node != nil && nav.Node.NamedChildCount() > 0 {
|
|
recv = strings.TrimSpace(nav.Node.NamedChild(0).Content(src))
|
|
}
|
|
// Only chained-factory member calls (the receiver is itself a call)
|
|
// are captured here, so the bare-identifier query stays authoritative
|
|
// for ordinary obj.method() and the graph is not flooded.
|
|
if strings.Contains(recv, "(") {
|
|
calls = append(calls, swiftDeferredCall{
|
|
name: m.Captures["callm.name"].Text,
|
|
line: expr.StartLine + 1,
|
|
isMember: true,
|
|
receiver: recv,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Emit fallback container nodes for class/struct/actor/enum declarations the
|
|
// query missed (parse-error regions). `seen` already holds every container
|
|
// the query emitted, so this only fills gaps and never duplicates.
|
|
for _, ft := range fallbackTypes {
|
|
id := filePath + "::" + ft.name
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindType, Name: ft.name,
|
|
FilePath: filePath, StartLine: ft.startLine + 1, EndLine: ft.endLine + 1,
|
|
Language: "swift",
|
|
Meta: map[string]any{"visibility": ft.visibility},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: ft.startLine + 1,
|
|
})
|
|
}
|
|
|
|
// Stamp protocol method names onto protocol nodes' Meta["methods"].
|
|
for _, n := range result.Nodes {
|
|
if n.Kind != graph.KindInterface {
|
|
continue
|
|
}
|
|
if methods, ok := protoMethods[n.Name]; ok {
|
|
if n.Meta == nil {
|
|
n.Meta = make(map[string]any)
|
|
}
|
|
n.Meta["methods"] = methods
|
|
}
|
|
}
|
|
|
|
// Resolve calls against funcRanges.
|
|
funcRanges := buildFuncRanges(result)
|
|
for _, c := range calls {
|
|
callerID := findEnclosingFunc(funcRanges, c.line)
|
|
if callerID == "" {
|
|
continue
|
|
}
|
|
to := "unresolved::" + c.name
|
|
if c.isMember {
|
|
to = "unresolved::*." + c.name
|
|
}
|
|
edge := &graph.Edge{
|
|
From: callerID, To: to,
|
|
Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
|
|
}
|
|
if c.isMember && c.receiver != "" {
|
|
stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, nil, result))
|
|
}
|
|
result.Edges = append(result.Edges, edge)
|
|
}
|
|
|
|
// React Native native event emits pair with the JS addListener handler.
|
|
mineRNNativeEmits(src, rnSendEventWrapperRe, func(line int) string {
|
|
return findEnclosingFunc(funcRanges, line)
|
|
}, filePath, "swift", result)
|
|
|
|
// Closure-collection dispatch: stamp dispatcher/registrar field markers.
|
|
mineSwiftClosureCollections(src, funcRanges, result)
|
|
|
|
// Structural reference forms a type-annotation extractor misses:
|
|
// instantiation (`Foo()` / `Foo.init`), inheritance / conformance
|
|
// (`class X: Base, Proto`), casts / type tests (`x as Foo`, `x is Foo`),
|
|
// and static / member access (`Foo.shared`). find_usages then lands these
|
|
// LSP-free. Type-annotation edges are already emitted as EdgeTypedAs above.
|
|
emitSwiftReferenceForms(root, src, filePath, fileID, funcRanges, typeRanges, result)
|
|
|
|
// Expo Modules native DSL (Name/Function/AsyncFunction) → synthetic
|
|
// JS-callable method nodes for the Expo bridge synthesizer.
|
|
emitExpoModuleNodes(src, filePath, "swift", fileID, result, seen)
|
|
|
|
captureValueRefCandidates(result, root, filePath, src)
|
|
captureFnValueCandidates(result, root, filePath, src)
|
|
captureAppleUIRoles(result, root, filePath, src)
|
|
return result, nil
|
|
}
|
|
|
|
// --- Per-match emit helpers -----------------------------------------
|
|
|
|
// emitTypeContainer emits a class / struct / enum node and records its
|
|
// line range so subsequent function_declaration dispatches can classify
|
|
// methods by enclosing type. The capture-name prefix selects which
|
|
// name/def pair to read. For the "enum" prefix, when the same id is
|
|
// already seen (i.e. swQClass already emitted it), stamps
|
|
// Meta["kind"]="enum" on the existing node and walks bodyNode for
|
|
// case entries instead of emitting a duplicate.
|
|
func (e *SwiftExtractor) emitTypeContainer(m parser.QueryResult, prefix, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, typeRanges *[]swiftTypeRange, bodyNode *sitter.Node) {
|
|
var nameKey, defKey string
|
|
switch prefix {
|
|
case "enum":
|
|
nameKey, defKey = "enum.name", "enum.def"
|
|
default:
|
|
nameKey, defKey = "class.name", "class.def"
|
|
}
|
|
name := m.Captures[nameKey].Text
|
|
def := m.Captures[defKey]
|
|
id := filePath + "::" + name
|
|
|
|
// Always extend the type-range table — this is what method
|
|
// classification consults. Adding the same id twice (once for
|
|
// class.def, once for enum.def on the same enum) is harmless: the
|
|
// findEnclosingType lookup picks the innermost match by size.
|
|
*typeRanges = append(*typeRanges, swiftTypeRange{
|
|
name: name,
|
|
startLine: def.StartLine,
|
|
endLine: def.EndLine,
|
|
objcMembers: swiftHasAttr(def.Node, "objcMembers", src),
|
|
})
|
|
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
meta := map[string]any{"visibility": swiftVisibility(def.Node, src)}
|
|
if prefix == "enum" {
|
|
meta["kind"] = "enum"
|
|
}
|
|
// Structural flavor: the class.* capture covers class/struct/actor
|
|
// alike, so the `prefix` param alone can't tell them apart — read the
|
|
// leading declaration keyword off the decl node. enum is determined
|
|
// by the prefix (its own capture).
|
|
flavor := "class"
|
|
if kw := swiftDeclKeyword(def.Node); kw != "" {
|
|
flavor = kw
|
|
} else if prefix == "enum" {
|
|
flavor = "enum"
|
|
}
|
|
meta["type_flavor"] = flavor
|
|
if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); 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: "swift",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
emitSwiftAnnotationEdges(def.Node, id, filePath, src, result, annotationSeen)
|
|
} else if prefix == "enum" {
|
|
// Backfill enum kind on the existing node.
|
|
for _, n := range result.Nodes {
|
|
if n.ID == id {
|
|
if n.Meta == nil {
|
|
n.Meta = make(map[string]any)
|
|
}
|
|
n.Meta["kind"] = "enum"
|
|
n.Meta["type_flavor"] = "enum"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enum cases — cases with associated values contain nested
|
|
// simple_identifier labels (`case labeled(x: Int)` has `x` as a
|
|
// simple_identifier), so we take *only the first* simple_identifier
|
|
// child of each enum_entry as the case name.
|
|
if prefix != "enum" || bodyNode == nil {
|
|
return
|
|
}
|
|
for i, _nc := 0, int(bodyNode.ChildCount()); i < _nc; i++ {
|
|
entry := bodyNode.Child(i)
|
|
if entry == nil || entry.Type() != "enum_entry" {
|
|
continue
|
|
}
|
|
var caseName string
|
|
for j, _nc := 0, int(entry.ChildCount()); j < _nc; j++ {
|
|
ch := entry.Child(j)
|
|
if ch != nil && ch.Type() == "simple_identifier" {
|
|
caseName = ch.Content(src)
|
|
break
|
|
}
|
|
}
|
|
if caseName == "" {
|
|
continue
|
|
}
|
|
caseID := id + "." + caseName
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: caseID, Kind: graph.KindVariable, Name: caseName,
|
|
FilePath: filePath,
|
|
StartLine: int(entry.StartPoint().Row) + 1,
|
|
EndLine: int(entry.EndPoint().Row) + 1,
|
|
Language: "swift",
|
|
Meta: map[string]any{"receiver": name, "kind": "enum_case"},
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: caseID, To: id, Kind: graph.EdgeMemberOf,
|
|
FilePath: filePath, Line: int(entry.StartPoint().Row) + 1,
|
|
})
|
|
}
|
|
}
|
|
|
|
// swiftDeclKeyword reads the leading declaration keyword token off a
|
|
// class.* capture node. tree-sitter-swift folds class/struct/actor/enum
|
|
// into one class_declaration rule, so the keyword token is what tells
|
|
// them apart. Returns "" when no recognised keyword is a direct child.
|
|
func swiftDeclKeyword(node *sitter.Node) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
for i, n := 0, int(node.ChildCount()); i < n; i++ {
|
|
ch := node.Child(i)
|
|
if ch == nil {
|
|
continue
|
|
}
|
|
switch ch.Type() {
|
|
case "class", "struct", "actor", "enum":
|
|
return ch.Type()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// recordProtocolMethod walks up to the enclosing protocol_declaration
|
|
// and appends the method name to its Meta["methods"] entry. Mirrors
|
|
// legacy swQProtocolMethod nested capture.
|
|
func (e *SwiftExtractor) recordProtocolMethod(m parser.QueryResult, src []byte, protoMethods map[string][]string) {
|
|
def := m.Captures["protomethod.def"]
|
|
if def.Node == nil {
|
|
return
|
|
}
|
|
protoNode := findEnclosingSwiftContainer(def.Node, "protocol_declaration")
|
|
if protoNode == nil {
|
|
return
|
|
}
|
|
nameNode := protoNode.ChildByFieldName("name")
|
|
if nameNode == nil {
|
|
return
|
|
}
|
|
protoMethods[nameNode.Content(src)] = append(protoMethods[nameNode.Content(src)], m.Captures["protomethod.name"].Text)
|
|
}
|
|
|
|
func (e *SwiftExtractor) emitProtocol(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
|
|
name := m.Captures["proto.name"].Text
|
|
def := m.Captures["proto.def"]
|
|
id := filePath + "::" + name
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
meta := map[string]any{"visibility": swiftVisibility(def.Node, src), "type_flavor": "protocol"}
|
|
if swiftHasAttr(def.Node, "objc", src) {
|
|
meta["objc"] = true
|
|
}
|
|
if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindInterface, Name: name,
|
|
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
|
Language: "swift",
|
|
Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
emitSwiftAnnotationEdges(def.Node, id, filePath, src, result, annotationSeen)
|
|
}
|
|
|
|
func (e *SwiftExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, typeRanges []swiftTypeRange) {
|
|
name := m.Captures["func.name"].Text
|
|
def := m.Captures["func.def"]
|
|
startLine := def.StartLine
|
|
|
|
doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash)
|
|
visibility := swiftVisibility(def.Node, src)
|
|
sig, returnType, isAsync, isStatic := swiftFunctionDetails(def.Node, src)
|
|
if sig == "" {
|
|
sig = "func " + name + "(...)"
|
|
}
|
|
|
|
if tr, ok := findEnclosingSwiftTypeRange(typeRanges, startLine); ok {
|
|
typeName := tr.name
|
|
id, idOK := disambiguateID(seen, filePath+"::"+typeName+"."+name, def.StartLine+1)
|
|
if !idOK {
|
|
return
|
|
}
|
|
meta := map[string]any{
|
|
"receiver": typeName,
|
|
"signature": sig,
|
|
"visibility": visibility,
|
|
}
|
|
swiftStampFuncMeta(meta, returnType, isAsync, isStatic)
|
|
if doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
if sel := swiftObjCSelectorExposed(def.Node, name, tr.objcMembers, src); sel != "" {
|
|
meta["objc_selector"] = sel
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindMethod, Name: name,
|
|
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
|
Language: "swift", Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
typeID := filePath + "::" + typeName
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: id, To: typeID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
emitSwiftAnnotationEdges(def.Node, id, filePath, src, result, annotationSeen)
|
|
emitSwiftFunctionTypeEdges(id, def.Node, src, filePath, def.StartLine+1, result)
|
|
return
|
|
}
|
|
|
|
id, idOK := disambiguateID(seen, filePath+"::"+name, def.StartLine+1)
|
|
if !idOK {
|
|
return
|
|
}
|
|
meta := map[string]any{
|
|
"signature": sig,
|
|
"visibility": visibility,
|
|
}
|
|
swiftStampFuncMeta(meta, returnType, isAsync, isStatic)
|
|
if doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: graph.KindFunction, Name: name,
|
|
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
|
Language: "swift", Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
emitSwiftAnnotationEdges(def.Node, id, filePath, src, result, annotationSeen)
|
|
emitSwiftFunctionTypeEdges(id, def.Node, src, filePath, def.StartLine+1, result)
|
|
}
|
|
|
|
// emitProperty extracts a stored property declaration. Inside a type it is a
|
|
// field member; at file scope it is a constant (`let`) or variable (`var`).
|
|
// When the property carries @objc the Objective-C accessor selectors it is
|
|
// exposed under are stamped (`objc_selector` getter, `objc_setter_selector` for
|
|
// a mutable var) so the Swift↔ObjC bridge can pair it with native accessors.
|
|
func (e *SwiftExtractor) emitProperty(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, typeRanges []swiftTypeRange) {
|
|
nameCap := m.Captures["property.name"]
|
|
def := m.Captures["property.def"]
|
|
if nameCap == nil || def == nil || nameCap.Text == "" {
|
|
return
|
|
}
|
|
name := nameCap.Text
|
|
mutable := swiftPropertyIsMutable(def.Node, src)
|
|
fieldType := swiftPropertyType(def.Node, src)
|
|
|
|
tr, enclosed := findEnclosingSwiftTypeRange(typeRanges, def.StartLine)
|
|
typeName := tr.name
|
|
kind := graph.KindField
|
|
id := filePath + "::" + name
|
|
if enclosed {
|
|
id = filePath + "::" + typeName + "." + name
|
|
} else if mutable {
|
|
kind = graph.KindVariable
|
|
} else {
|
|
kind = graph.KindConstant
|
|
}
|
|
if seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
|
|
meta := map[string]any{"visibility": swiftVisibility(def.Node, src)}
|
|
if mutable {
|
|
meta["mutable"] = true
|
|
}
|
|
if fieldType != "" {
|
|
meta["field_type"] = fieldType
|
|
}
|
|
if enclosed {
|
|
meta["receiver"] = typeName
|
|
}
|
|
if getter, setter := swiftObjCPropertySelectorsExposed(def.Node, name, mutable, enclosed && tr.objcMembers, src); getter != "" {
|
|
meta["objc_selector"] = getter
|
|
if setter != "" {
|
|
meta["objc_setter_selector"] = setter
|
|
}
|
|
}
|
|
if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" {
|
|
meta["doc"] = doc
|
|
}
|
|
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: id, Kind: kind, Name: name,
|
|
FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
|
|
Language: "swift", Meta: meta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
if enclosed {
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: id, To: filePath + "::" + typeName, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
}
|
|
if fieldType != "" {
|
|
// Emit the variable / property / local annotation as a type-use
|
|
// edge. Routing through emitSwiftTypeUseEdges skips Swift
|
|
// primitives (no `unresolved::Int` noise), stamps
|
|
// OriginASTInferred, and decomposes composite annotations
|
|
// (`[Foo]`, `Foo?`, `[K: V]`, `Bar<Baz>`) into per-leaf edges so
|
|
// a type used only in annotation position is reachable by
|
|
// find_usages without a language server.
|
|
emitSwiftTypeUseEdges(id, fieldType, filePath, def.StartLine+1, result)
|
|
}
|
|
emitSwiftAnnotationEdges(def.Node, id, filePath, src, result, annotationSeen)
|
|
}
|
|
|
|
// swiftPropertyIsMutable reports whether a property is declared with `var`
|
|
// (mutable) rather than `let`, reading the value_binding_pattern child.
|
|
func swiftPropertyIsMutable(decl *sitter.Node, src []byte) bool {
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
if c := decl.NamedChild(i); c != nil && c.Type() == "value_binding_pattern" {
|
|
return strings.Contains(c.Content(src), "var")
|
|
}
|
|
}
|
|
return strings.Contains(decl.Content(src), "var ")
|
|
}
|
|
|
|
// swiftPropertyType returns the base type named in a property's
|
|
// type_annotation (`: [Foo]?` → "Foo"), or "" when the type is inferred.
|
|
func swiftPropertyType(decl *sitter.Node, src []byte) string {
|
|
for i, _nc := 0, int(decl.NamedChildCount()); i < _nc; i++ {
|
|
c := decl.NamedChild(i)
|
|
if c == nil || c.Type() != "type_annotation" {
|
|
continue
|
|
}
|
|
t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c.Content(src)), ":"))
|
|
return swiftBaseTypeName(t)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// swiftBaseTypeName reduces a Swift type expression to its leaf type name,
|
|
// stripping opaque/existential markers, optionals, array/dictionary sugar,
|
|
// generic arguments and module qualification.
|
|
func swiftBaseTypeName(t string) string {
|
|
t = strings.TrimSpace(t)
|
|
t = strings.TrimPrefix(t, "some ")
|
|
t = strings.TrimPrefix(t, "any ")
|
|
t = strings.TrimRight(t, "?!")
|
|
t = strings.Trim(t, "[]")
|
|
if idx := strings.IndexByte(t, '<'); idx >= 0 {
|
|
t = t[:idx]
|
|
}
|
|
if idx := strings.IndexByte(t, ':'); idx >= 0 { // dictionary value type
|
|
t = strings.TrimSpace(t[idx+1:])
|
|
}
|
|
if idx := strings.LastIndexByte(t, '.'); idx >= 0 {
|
|
t = t[idx+1:]
|
|
}
|
|
return strings.TrimSpace(t)
|
|
}
|
|
|
|
// swiftFunctionDetails parses a function declaration's header for its real
|
|
// signature, return type, and async/static modifier flags. The body is dropped
|
|
// at the first brace; modifiers precede `func` and the return type follows `->`.
|
|
func swiftFunctionDetails(decl *sitter.Node, src []byte) (signature, returnType string, isAsync, isStatic bool) {
|
|
if decl == nil {
|
|
return "", "", false, false
|
|
}
|
|
header := decl.Content(src)
|
|
if i := strings.IndexByte(header, '{'); i >= 0 {
|
|
header = header[:i]
|
|
}
|
|
header = strings.Join(strings.Fields(header), " ")
|
|
isAsync = swiftHasWord(header, "async")
|
|
fi := strings.Index(header, "func ")
|
|
if fi < 0 {
|
|
return strings.TrimSpace(header), "", isAsync, false
|
|
}
|
|
isStatic = swiftHasWord(header[:fi], "static") || swiftHasWord(header[:fi], "class")
|
|
signature = strings.TrimSpace(header[fi:])
|
|
if ri := strings.Index(signature, "->"); ri >= 0 {
|
|
rt := strings.TrimSpace(signature[ri+2:])
|
|
if wi := strings.Index(rt, " where "); wi >= 0 {
|
|
rt = strings.TrimSpace(rt[:wi])
|
|
}
|
|
rt = strings.TrimPrefix(rt, "some ")
|
|
rt = strings.TrimPrefix(rt, "any ")
|
|
returnType = strings.TrimSpace(rt)
|
|
}
|
|
return signature, returnType, isAsync, isStatic
|
|
}
|
|
|
|
// swiftHasWord reports whether word appears as a standalone space-delimited
|
|
// token in s.
|
|
func swiftHasWord(s, word string) bool {
|
|
for _, f := range strings.Fields(s) {
|
|
if f == word {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// swiftStampFuncMeta records the return type and async/static flags on a
|
|
// function or method node's Meta when present.
|
|
func swiftStampFuncMeta(meta map[string]any, returnType string, isAsync, isStatic bool) {
|
|
if returnType != "" {
|
|
meta["return_type"] = returnType
|
|
}
|
|
if isAsync {
|
|
meta["is_async"] = true
|
|
}
|
|
if isStatic {
|
|
meta["is_static"] = true
|
|
}
|
|
}
|
|
|
|
var swiftExtensionRe = regexp.MustCompile(`(?m)^[ \t]*(?:(?:public|private|internal|fileprivate|open|final)[ \t]+)*extension[ \t]+([A-Za-z_][\w.]*)`)
|
|
|
|
// swiftExtensionRanges returns a type-range per `extension Foo { ... }` block in
|
|
// src (Foo collapsed to its last dotted segment), found by a brace-matched text
|
|
// scan since the tree-sitter query does not capture extension declarations.
|
|
func swiftExtensionRanges(src []byte) []swiftTypeRange {
|
|
s := string(src)
|
|
var ranges []swiftTypeRange
|
|
for _, loc := range swiftExtensionRe.FindAllStringSubmatchIndex(s, -1) {
|
|
typeName := s[loc[2]:loc[3]]
|
|
if i := strings.LastIndexByte(typeName, '.'); i >= 0 {
|
|
typeName = typeName[i+1:]
|
|
}
|
|
rel := strings.IndexByte(s[loc[1]:], '{')
|
|
if rel < 0 {
|
|
continue
|
|
}
|
|
open := loc[1] + rel
|
|
end := swiftMatchBrace(s, open)
|
|
if end < 0 {
|
|
continue
|
|
}
|
|
ranges = append(ranges, swiftTypeRange{
|
|
name: typeName,
|
|
startLine: strings.Count(s[:open], "\n"),
|
|
endLine: strings.Count(s[:end], "\n"),
|
|
})
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
// swiftTypeDeclRe matches a class / struct / actor / enum declaration header at
|
|
// the start of a line, capturing the type name. Leading attributes (`@objc`)
|
|
// and access/other modifiers are skipped. Used as a parse-error resilience net
|
|
// (see swiftFallbackTypeDecls).
|
|
var swiftTypeDeclRe = regexp.MustCompile(`(?m)^[ \t]*(?:@[A-Za-z_]\w*(?:\([^)]*\))?[ \t]+)*(?:(?:public|private|internal|fileprivate|open|final|indirect)[ \t]+)*(?:class|struct|actor|enum)[ \t]+([A-Za-z_]\w*)`)
|
|
|
|
type swiftFallbackType struct {
|
|
name string
|
|
startLine int // 0-based
|
|
endLine int // 0-based
|
|
visibility string
|
|
}
|
|
|
|
// swiftFallbackTypeDecls finds class / struct / actor / enum declarations by a
|
|
// brace-matched text scan — the resilience net for when a tree-sitter parse
|
|
// error (e.g. an unparseable `#if … && !canImport(...)` inside a body) corrupts
|
|
// the enclosing class_declaration so the query never matches it. Without this
|
|
// the container node is absent and its members + every find_usages reference to
|
|
// the type strand on unresolved::Name. Mirrors swiftExtensionRanges.
|
|
func swiftFallbackTypeDecls(src []byte) []swiftFallbackType {
|
|
s := string(src)
|
|
var out []swiftFallbackType
|
|
for _, loc := range swiftTypeDeclRe.FindAllStringSubmatchIndex(s, -1) {
|
|
name := s[loc[2]:loc[3]]
|
|
rel := strings.IndexByte(s[loc[1]:], '{')
|
|
if rel < 0 {
|
|
continue
|
|
}
|
|
open := loc[1] + rel
|
|
end := swiftMatchBrace(s, open)
|
|
if end < 0 {
|
|
continue
|
|
}
|
|
vis := VisibilityInternal
|
|
switch prefix := s[loc[0]:loc[1]]; {
|
|
case strings.Contains(prefix, "public"), strings.Contains(prefix, "open"):
|
|
vis = VisibilityPublic
|
|
case strings.Contains(prefix, "private"), strings.Contains(prefix, "fileprivate"):
|
|
vis = VisibilityPrivate
|
|
}
|
|
out = append(out, swiftFallbackType{
|
|
name: name,
|
|
startLine: strings.Count(s[:open], "\n"),
|
|
endLine: strings.Count(s[:end], "\n"),
|
|
visibility: vis,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// swiftMatchBrace returns the index of the '}' that closes the '{' at open, or
|
|
// -1 when unbalanced.
|
|
func swiftMatchBrace(s string, open int) int {
|
|
depth := 0
|
|
for i := open; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// swiftVisibility scans a declaration's leading modifier children for
|
|
// an access-level keyword. Swift's default is "internal" when no
|
|
// modifier is present. The grammar emits modifiers as plain keyword
|
|
// children of the declaration node (visibility_modifier etc.).
|
|
func swiftVisibility(decl *sitter.Node, src []byte) string {
|
|
if decl == nil {
|
|
return VisibilityInternal
|
|
}
|
|
for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ {
|
|
c := decl.Child(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
// Stop scanning once we pass the leading modifier band — once
|
|
// we hit `func` / `class` / `struct` / `protocol` etc. there
|
|
// are no more access modifiers ahead.
|
|
t := c.Type()
|
|
if t == "modifiers" {
|
|
// Some grammar versions wrap modifiers; recurse.
|
|
if v := swiftVisibility(c, src); v != VisibilityInternal {
|
|
return v
|
|
}
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(c.Content(src)) {
|
|
case "public":
|
|
return VisibilityPublic
|
|
case "open":
|
|
return VisibilityPublic
|
|
case "private":
|
|
return VisibilityPrivate
|
|
case "fileprivate":
|
|
return VisibilityPrivate
|
|
case "internal":
|
|
return VisibilityInternal
|
|
}
|
|
}
|
|
return VisibilityInternal
|
|
}
|
|
|
|
func (e *SwiftExtractor) emitImport(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) {
|
|
def := m.Captures["import.def"]
|
|
importText := strings.TrimSpace(def.Text)
|
|
importText = strings.TrimPrefix(importText, "import ")
|
|
importText = strings.TrimSpace(importText)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: "unresolved::import::" + importText,
|
|
Kind: graph.EdgeImports, FilePath: filePath, Line: def.StartLine + 1,
|
|
})
|
|
}
|
|
|
|
// --- Helpers --------------------------------------------------------
|
|
|
|
// findEnclosingSwiftType returns the innermost type whose line range
|
|
// contains the 0-based line. Mirrors the legacy findEnclosingType
|
|
// logic — picks the smallest enclosing range so nested types attribute
|
|
// correctly.
|
|
func findEnclosingSwiftType(ranges []swiftTypeRange, line int) (string, bool) {
|
|
r, ok := findEnclosingSwiftTypeRange(ranges, line)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return r.name, true
|
|
}
|
|
|
|
// findEnclosingSwiftTypeRange returns the innermost (smallest) type range
|
|
// containing line, so a member can read its enclosing type's attributes
|
|
// (e.g. @objcMembers), not just the type name.
|
|
func findEnclosingSwiftTypeRange(ranges []swiftTypeRange, line int) (swiftTypeRange, bool) {
|
|
var best swiftTypeRange
|
|
found := false
|
|
bestSize := int(^uint(0) >> 1)
|
|
for _, r := range ranges {
|
|
if line >= r.startLine && line <= r.endLine {
|
|
size := r.endLine - r.startLine
|
|
if size < bestSize {
|
|
bestSize = size
|
|
best = r
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
// A type can hold two enclosing ranges -- the brace-matched fallback
|
|
// scan seeded before the query match, and the query match itself. Only
|
|
// the latter carries attributes, so OR the @objcMembers flag across
|
|
// every same-name range that contains the line.
|
|
if found && !best.objcMembers {
|
|
for _, r := range ranges {
|
|
if r.name == best.name && r.objcMembers && line >= r.startLine && line <= r.endLine {
|
|
best.objcMembers = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
// findEnclosingSwiftContainer walks the parent chain of n looking for
|
|
// the nearest ancestor whose Type() matches t. Returns nil if none.
|
|
func findEnclosingSwiftContainer(n *sitter.Node, t string) *sitter.Node {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
for p := n.Parent(); p != nil; p = p.Parent() {
|
|
if p.Type() == t {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|