cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,2890 @@
1
+ """Swift language plugin (iOS / macOS apps, SwiftUI, Vapor).
2
+
3
+ Heuristic mode: a tree-sitter-swift syntax layer, so repositories index on Linux without Xcode or a Swift toolchain.
4
+ Declarations (classes, structs, enums, actors, protocols, extensions, functions, methods, initializers, SwiftUI `body`
5
+ properties) become nodes; calls are resolved by name: the enclosing type, its extensions and supertypes, then a
6
+ parameter / property type (`api.book(id)` with `let api: BooksAPI`), then a project-wide unique name. Every resolved
7
+ reference is labelled `heuristic`. Extensions are merged into the type they extend.
8
+
9
+ Exact mode (exact.py, indexstore.py): the compiler's index store (`swift build --enable-index-store`, run with
10
+ CODEGRAPH_SWIFT_INDEX=1 for a SwiftPM package, or an existing store / Xcode DerivedData via
11
+ CODEGRAPH_SWIFT_INDEX_STORE) read through libIndexStore replaces the call / constructor edges of every file the store
12
+ covers; files it does not cover keep heuristic edges, and `cg coverage` names the mode and the reason.
13
+
14
+ Framework facts read from the same syntax tree:
15
+ entry points `@main` types (main), `UIApplicationDelegate` / `UISceneDelegate` / `App` lifecycle callbacks,
16
+ `BGTaskScheduler.shared.register(forTaskWithIdentifier:)` handlers (queue_job); tests: XCTest `test*`
17
+ methods of XCTestCase subclasses (attrs.framework xctest) and Swift Testing `@Test` functions, a
18
+ parameterized `@Test(arguments:)` included (swift-testing; display name, tags and traits from the
19
+ attribute); `@Suite` types carry attrs.suite. Test files: Tests/, *Tests/ targets, or `import
20
+ XCTest` / `import Testing`
21
+ SwiftUI `NavigationLink(destination: V())`, `.navigationDestination { V() }`, `.sheet` / `.fullScreenCover` /
22
+ `.popover { V() }`, `TabView` children and the `WindowGroup` root, `navigationDestination(for: T.self)`
23
+ cases matched to `NavigationLink(value: T.x)` / `router.navigate(to: .x)`: the target views become page nodes
24
+ (`page:swift:<View>`, ROUTES_TO its `body`) with NAVIGATES_TO edges; UIKit
25
+ `pushViewController(V(), ...)` / `present(V(), ...)` likewise
26
+ HTTP clients URLSession (`data(from:)`, `data(for:)`, `dataTask`, `upload(for:)`) with the URL built in the same
27
+ function (`URL(string: "...")`, `appendingPathComponent("...")`, `"\\(baseURL)/..."` templates) and
28
+ `httpMethod = "POST"`; Alamofire `AF.request(url, method: .post)` -> http:<METHOD> <path>
29
+ Vapor `app.get("orders", ":id") { }`, `routes.post("x", use: handler)`, `grouped("v1")` / `group("v1") { }`
30
+ prefixes, middleware passed to `grouped(...)` (`User.authenticator()`, `User.guardMiddleware()`) as
31
+ route guards, `RouteCollection.boot(routes:)` -> route:<METHOD> <uri>
32
+ Moya `TargetType` enums (baseURL + per-case path / method) -> one http node per case (HTTP_CALLS from the
33
+ enum), `provider.request(.case)` / `requestPublisher` call sites -> HTTP_CALLS from the caller
34
+ Fluent `Model` classes with `static let schema = "todos"` -> table:todos (MAPS_TO_TABLE); migrations'
35
+ `database.schema("todos")...create()` -> WRITES_TABLE; `Todo.query(on:)` / `Todo.find` -> READS_TABLE
36
+ (WRITES_TABLE when the chain deletes / updates); `todo.save(on:)` / `.delete(on:)` -> WRITES_TABLE
37
+ platforms `#if os(iOS)` / `#elseif os(macOS)` / `#else` blocks feed the platform tags (docs/platforms.md)
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import re
43
+ import time
44
+ from types import SimpleNamespace
45
+ from collections import defaultdict
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+
49
+ from ...core.syntax_errors import tree_spans
50
+ from ...core.fsutil import keep_file
51
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
52
+
53
+ KEYPATH_STDLIB = frozenset("""self offset element count isEmpty first last key value keys values rawValue description
54
+ debugDescription hashValue id startIndex endIndex length name""".split())
55
+ from ...core.paths import rules as path_rules
56
+ from ...core.plugin import GraphBuilder, LanguagePlugin, Project
57
+ from ..native.ts import TreeSitterMissing
58
+
59
+ VALUE_SITES = frozenset({"navigation_expression", "call_expression", "simple_identifier", "switch_statement",
60
+ "equality_expression", "property_declaration", "parameter"})
61
+
62
+ EXTS = (".swift",)
63
+ BRANCH_STOP = frozenset({"function_declaration", "init_declaration", "class_declaration", "protocol_declaration",
64
+ "computed_property", "subscript_declaration", "source_file", "property_declaration"})
65
+ VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
66
+ TEST_IMPORTS = {"XCTest", "Testing"}
67
+ TEST_PATH = re.compile(r"(^|/)(Tests?|\w+Tests|\w+UITests)/|Tests?\.swift$")
68
+ TEMPLATE = re.compile(r"\\\(([^()]*(?:\([^()]*\)[^()]*)*)\)")
69
+ LIFECYCLE = re.compile(r"^(application\w*|scene\w*|sceneDid\w*|sceneWill\w*|viewDidLoad|viewWillAppear|viewDidAppear|"
70
+ r"userNotificationCenter|perform|handle|body)$")
71
+ LIFECYCLE_BASES = {"UIApplicationDelegate", "UIWindowSceneDelegate", "UISceneDelegate", "NSApplicationDelegate",
72
+ "UIViewController", "AppIntent", "Widget", "WKApplicationDelegate"}
73
+ URLSESSION = re.compile(r"\b(data|dataTask|upload|uploadTask|download|downloadTask|bytes)\s*\(\s*(from|for|with)\s*:")
74
+ # Sequence / Collection / Dictionary / Optional / String members: a call of one of these on a receiver of unknown type
75
+ # is not resolved to a same-named project method by the unique-name fallback
76
+ STDLIB_METHODS = {
77
+ "first", "last", "filter", "reduce", "sorted", "sort", "enumerated", "compactMap", "flatMap", "forEach",
78
+ "contains", "append", "insert", "remove", "removeAll", "removeFirst", "removeLast", "joined", "reversed",
79
+ "prefix", "suffix", "dropFirst", "dropLast", "min", "max", "split", "firstIndex", "lastIndex", "mapValues",
80
+ "compactMapValues", "merge", "merging", "updateValue", "index", "starts", "allSatisfy", "lowercased",
81
+ "uppercased", "replacingOccurrences", "trimmingCharacters", "components", "hasPrefix", "hasSuffix", "zip",
82
+ "shuffled", "partition", "subtracting", "union", "intersection", "formUnion", "isEmpty", "count", "makeIterator",
83
+ "withContiguousStorageIfAvailable", "encode", "decode", "description", "hash", "elementsEqual"}
84
+ # selectors (name + argument labels) of SDK members commonly called on receivers cg cannot type: a call with one of
85
+ # these selectors on an unknown receiver is not bound to a same-selector project method (#70)
86
+ SDK_SELECTORS = {
87
+ "contains(_:)", "contains(where:)", "first(where:)", "firstIndex(of:)", "firstIndex(where:)", "remove(at:)",
88
+ "append(_:)", "append(contentsOf:)", "insert(_:at:)", "insert(_:)", "remove(_:)", "index(of:)", "index(_:offsetBy:)",
89
+ "filter(_:)", "map(_:)", "compactMap(_:)", "sorted(by:)", "joined(separator:)", "split(separator:)",
90
+ "accessibilityIdentifier(_:)", "accessibilityLabel(_:)", "accessibilityHint(_:)", "accessibilityValue(_:)",
91
+ "accessibilityAddTraits(_:)", "accessibilityHidden(_:)", "font(_:)", "weight(_:)", "bold()", "bold(_:)",
92
+ "italic()", "foregroundColor(_:)", "foregroundStyle(_:)", "background(_:)", "overlay(_:)", "padding(_:)",
93
+ "padding()", "padding(_:_:)", "opacity(_:)", "tint(_:)", "tag(_:)", "id(_:)", "frame(width:height:)",
94
+ "disabled(_:)", "hidden()", "navigationTitle(_:)", "offset(x:y:)", "scaleEffect(_:)", "rotationEffect(_:)",
95
+ "clipShape(_:)", "cornerRadius(_:)", "shadow(radius:)", "animation(_:value:)", "transition(_:)", "zIndex(_:)",
96
+ "onAppear(perform:)", "onDisappear(perform:)", "onTapGesture(perform:)", "task(_:)", "task(id:_:)",
97
+ "resume()", "resume(returning:)", "resume(throwing:)", "resume(with:)", "yield(_:)", "finish()",
98
+ "post(name:object:)", "post(name:object:userInfo:)", "post(_:)", "addObserver(_:selector:name:object:)",
99
+ "removeObserver(_:)", "open(_:)", "open(_:options:completionHandler:)", "canOpenURL(_:)",
100
+ "draw(_:at:)", "draw(_:in:)", "draw(_:at:anchor:)", "fill(_:with:)", "stroke(_:with:lineWidth:)",
101
+ "read(_:maxLength:)", "write(_:maxLength:)", "cancel()", "send(_:)", "sink(receiveValue:)",
102
+ "set(_:forKey:)", "value(forKey:)", "object(forKey:)", "removeObject(forKey:)", "string(forKey:)",
103
+ "async(execute:)", "asyncAfter(deadline:execute:)", "sync(execute:)", "sleep(nanoseconds:)", "sleep(for:)",
104
+ "dataTask(with:)", "data(from:)", "data(for:)", "encode(_:)", "decode(_:from:)", "start()", "stop()",
105
+ "reloadData()", "dismiss(animated:completion:)", "present(_:animated:completion:)", "layoutIfNeeded()",
106
+ "setNeedsLayout()", "addSubview(_:)", "removeFromSuperview()", "becomeFirstResponder()", "resignFirstResponder()",
107
+ }
108
+ # argument labels of standard-library collection members (`xs.first(where:)`, `s.split(separator:)`): a call of a
109
+ # STDLIB_METHODS name with only these labels stays unbound on an unknown receiver; other labels name a project method
110
+ STDLIB_LABELS = {"_", "where", "at", "by", "of", "into", "separator", "with", "contentsOf", "keepingCapacity",
111
+ "maxSplits", "omittingEmptySubsequences", "offsetBy", "limitedBy", "from", "to", "through",
112
+ "uniquingKeysWith", "isIncluded", "options", "range", "locale", "in", "forKey"}
113
+ PRESENT = {"sheet", "fullScreenCover", "popover", "navigationDestination"}
114
+ CONTAINER_VIEWS = {"Text", "Image", "Label", "Button", "VStack", "HStack", "ZStack", "List", "Group", "NavigationStack",
115
+ "NavigationView", "ScrollView", "Form"}
116
+ NAV_RECV = re.compile(r"(?i)path|router|navigat|stack|coordinator")
117
+ OS_PLATFORM = {"iOS": "ios", "iPadOS": "ios", "watchOS": "watchos", "tvOS": "tvos", "visionOS": "visionos", "macOS": "macos",
118
+ "OSX": "macos", "Linux": "linux", "Windows": "windows", "Android": "android", "WASI": "web"}
119
+ # `#if canImport(X)`: the SDK framework implies the platform (UIKit: iOS family, AppKit: macOS)
120
+ # (Apple-only frameworks: iOS or macOS; swift-corelibs FoundationNetworking: not Apple)
121
+ IMPORT_PLATFORM = {"UIKit": ("ios", "tvos", "visionos"), "WatchKit": "watchos",
122
+ "MobileCoreServices": ("ios", "tvos", "watchos", "visionos"), "AppKit": "macos", "Cocoa": "macos",
123
+ "Glibc": "linux", "Musl": "linux", "WinSDK": "windows", "ucrt": "windows", "Android": "android",
124
+ "WASILibc": "web"}
125
+ APPLE_ONLY = {"Darwin", "Security", "Network", "SystemConfiguration", "UniformTypeIdentifiers", "Combine", "SwiftUI",
126
+ "CoreServices", "CoreLocation", "CoreData", "CoreGraphics", "CoreFoundation", "ObjectiveC", "os",
127
+ "StoreKit", "AVFoundation", "Metal", "CryptoKit", "UserNotifications", "WidgetKit"}
128
+ TYPE_DECLS = ("class_declaration", "protocol_declaration")
129
+ # a read of one of these on a receiver of unknown type is not bound to a project computed property by name (#72):
130
+ # far more often a stored or SDK property of the same name
131
+ COMMON_PROPS = {
132
+ "id", "name", "title", "value", "count", "first", "last", "isEmpty", "description", "debugDescription", "text",
133
+ "url", "type", "kind", "data", "date", "key", "label", "image", "color", "font", "state", "status", "items",
134
+ "content", "message", "error", "result", "index", "size", "width", "height", "frame", "bounds", "view",
135
+ "isEnabled", "isHidden", "isSelected", "isLoading", "rawValue", "hashValue", "body", "path", "host", "string",
136
+ "startIndex", "endIndex", "indices", "keys", "values", "lowercased", "uppercased", "shared", "default"}
137
+ ACCESSOR_CLAUSES = {"computed_getter": "get", "computed_setter": "set", "computed_modify": "modify",
138
+ "willset_clause": "willSet", "didset_clause": "didSet"}
139
+ # a bare identifier under one of these parents is not a property read (declaration names, labels, members, types)
140
+ NOT_A_READ = {"navigation_suffix", "pattern", "lambda_parameter", "parameter", "value_argument_label",
141
+ "function_declaration", "class_declaration", "protocol_declaration", "enum_entry", "import_declaration",
142
+ "user_type", "type_identifier", "tuple_type_item", "capture_list_item", "value_binding_pattern", "switch_pattern",
143
+ "key_path_expression", "attribute", "protocol_function_declaration", "typealias_declaration",
144
+ "init_declaration", "property_declaration", "protocol_property_declaration", "inheritance_specifier",
145
+ "macro_invocation", "external_macro_definition", "macro_declaration", "precedence_group_declaration"}
146
+ # an unknown receiver whose selector fits more project methods than this gets no candidate edges (#83 item 6)
147
+ MAX_CANDIDATES = 5
148
+ # standard-library mutating methods on a stored collection / Bool / Optional: `items.append(x)` writes `items` (#88)
149
+ MUTATING_METHODS = frozenset({
150
+ "append", "appendContents", "insert", "remove", "removeAll", "removeFirst", "removeLast", "removeSubrange",
151
+ "removeValue", "popFirst", "popLast", "replaceSubrange", "sort", "reverse", "shuffle", "swapAt", "toggle",
152
+ "updateValue", "merge", "formUnion", "formIntersection", "formSymmetricDifference", "subtract", "negate",
153
+ "move", "partition", "reserveCapacity"})
154
+ STATIC_MODS = {"static", "class"}
155
+ # a receiver whose SDK type is certain (`let r = UIGraphicsPDFRenderer(...)`, `var inside = false`,
156
+ # `Path { p in }`) does not reach a project extension of one of these other concrete SDK types: value types and
157
+ # final classes no SDK value of another type can be. `r.pdfData { }` does not reach `extension CGImage { func
158
+ # pdfData() }` (#83); an extension of a protocol or open class (`View`, `Reducer`, `UIView`) still can.
159
+ CONCRETE_SDK = {
160
+ "Bool", "Int", "Int8", "Int16", "Int32", "Int64", "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "Double",
161
+ "Float", "CGFloat", "Decimal", "String", "Substring", "Character", "Array", "ArraySlice", "Dictionary", "Set",
162
+ "Optional", "Result", "Range", "ClosedRange", "Data", "Date", "DateComponents", "URL", "URLComponents",
163
+ "URLRequest", "UUID", "Calendar", "Locale", "TimeZone", "IndexPath", "IndexSet", "AttributedString",
164
+ "CGRect", "CGPoint", "CGSize", "CGVector", "CGAffineTransform", "CGImage", "CGColor", "CGPath", "CGContext",
165
+ "Image", "Color", "Font", "Text", "Path", "Angle", "Edge", "EdgeInsets", "Animation", "Binding", "LocalizedStringKey",
166
+ "UIImage", "UIColor", "UIFont", "UIBezierPath", "NSImage", "NSColor", "NSFont", "NSBezierPath",
167
+ "NSAttributedString", "UIGraphicsPDFRenderer", "UIGraphicsImageRenderer", "JSONDecoder", "JSONEncoder",
168
+ "DateFormatter", "NumberFormatter", "URLSession", "FileManager", "UserDefaults", "Bundle", "NotificationCenter",
169
+ "DispatchQueue", "GeometryProxy", "ScrollViewProxy", "GraphicsContext"}
170
+ # the first closure parameter of these SDK builders (`Path { p in }`, `GeometryReader { proxy in }`): that SDK type
171
+ CLOSURE_PARAM_TYPES = {"Path": "Path", "GeometryReader": "GeometryProxy", "ScrollViewReader": "ScrollViewProxy",
172
+ "Canvas": "GraphicsContext"}
173
+ CLOSURE_PARAM = re.compile(r"\b(" + "|".join(CLOSURE_PARAM_TYPES) + r")\s*(?:\([^()]*\))?\s*\{\s*(\w+)\s*(?:,\s*\w+\s*)?in\b")
174
+ # `let x = <literal>`: the literal's standard-library type
175
+ LITERAL_TYPES = (("Bool", r"(?:true|false)\b"), ("Double", r"-?\d+\.\d+\b"), ("Int", r"-?\d+\b"), ("String", r'"'))
176
+ LOCAL_DECL = re.compile(
177
+ r"\b(?:let|var)\s+(\w+)\s*(?::\s*(?:some\s+|any\s+)?([A-Z][\w.]*))?\s*(?:=\s*(?:try\s*[?!]?\s+)?(?:await\s+)?"
178
+ r"(?:([A-Z]\w*)\s*(?:<[^=\n]*?>)?\s*\(|((?:true|false)\b|-?\d+\.\d+\b|-?\d+\b|\"))?)?")
179
+
180
+
181
+ def _static(d) -> bool:
182
+ return bool(STATIC_MODS & d.modifiers)
183
+
184
+
185
+ def parser():
186
+ try:
187
+ from tree_sitter import Language, Parser
188
+ import tree_sitter_swift as m
189
+ except ImportError as e: # pragma: no cover - depends on the environment
190
+ raise TreeSitterMissing(f"tree-sitter grammar for swift not installed ({e}); "
191
+ f"pip install tree-sitter tree-sitter-swift") from e
192
+ return Parser(Language(m.language()))
193
+
194
+
195
+ def source_files(root: Path, project=None) -> list[str]:
196
+ rules = path_rules(project, "swift")
197
+ out = []
198
+ for dp, dn, fn in os.walk(root):
199
+ rd = os.path.relpath(dp, root).replace(os.sep, "/")
200
+ rd = "" if rd == "." else rd
201
+ dn[:] = sorted(rules.prune(rd, dn, dot=True))
202
+ for f in sorted(fn):
203
+ if f.endswith(EXTS):
204
+ rel = f"{rd}/{f}" if rd else f
205
+ if keep_file(os.path.join(dp, f)) and not rules.excluded(rel):
206
+ out.append(rel)
207
+ return out
208
+
209
+
210
+ def template(raw: str) -> str:
211
+ """Swift string literal source -> URL template: "orders/\\(id)" -> orders/{id}, "\\(baseURL)/x" -> {baseURL}/x."""
212
+ s = raw.strip()
213
+ if s.startswith('"""'):
214
+ s = s[3:-3]
215
+ elif s.startswith('"'):
216
+ s = s[1:-1]
217
+
218
+ def rep(m):
219
+ name = re.sub(r"[^\w.]", "", m.group(1).split("(")[0]).split(".")[-1] or "?"
220
+ return "{" + name + "}"
221
+ return TEMPLATE.sub(rep, s)
222
+
223
+
224
+ def split_url(t: str) -> tuple[str | None, str]:
225
+ t = t.split("?")[0].split("#")[0]
226
+ m = re.match(r"^([a-zA-Z][\w+.-]*://[^/]*)(.*)$", t)
227
+ origin = None
228
+ if m:
229
+ origin, t = m.group(1), m.group(2)
230
+ else:
231
+ m = re.match(r"^(\{[^{}/]*\})(/.*|)$", t)
232
+ if m:
233
+ origin, t = m.group(1), m.group(2)
234
+ return origin, ("/" + t.lstrip("/")) if t else "/"
235
+
236
+
237
+ def join_path(base: str, p: str) -> str:
238
+ if not p:
239
+ return base or ""
240
+ return (base.rstrip("/") + "/" + p.lstrip("/")) if base else p
241
+
242
+
243
+ def vapor_seg(s: str) -> str:
244
+ return "{" + s[1:] + "}" if s.startswith(":") else s
245
+
246
+
247
+ @dataclass
248
+ class Decl:
249
+ id: str
250
+ kind: str
251
+ name: str
252
+ fqn: str
253
+ file: str
254
+ line: int
255
+ end: int
256
+ cls: str | None = None
257
+ supers: list = field(default_factory=list)
258
+ attributes: list = field(default_factory=list)
259
+ modifiers: set = field(default_factory=set)
260
+ types: dict = field(default_factory=dict)
261
+ test: bool = False
262
+ ranges: list = field(default_factory=list) # property node: [(first line, last line, "get" | "didSet" ...)]
263
+ owner: str | None = None # the containing type's node id when it is a per-`#if`-branch variant (`class:T@9`)
264
+
265
+
266
+ RAW_IDENT_DECL = re.compile(rb"\b(?:func|struct|class|enum|actor|extension|protocol|case|let|var)\s+`[^`\"\n\\]*[^\w`\"\n\\]")
267
+ RAW_IDENT = re.compile(rb"`([^`\n]*)`") # backticks pair up left to right on a line
268
+
269
+
270
+ def _raw_span(m) -> bytes:
271
+ body = m.group(1)
272
+ if not body or re.fullmatch(rb"\w+", body) or re.search(rb"[\"\\]", body):
273
+ return m.group(0)
274
+ return b"`" + re.sub(rb"\W", b"_", body) + b"`"
275
+
276
+
277
+ def _raw_identifiers(src: bytes) -> bytes:
278
+ """Swift 6.2 raw identifiers (`` @Test func `Sums the items`() ``, `` struct `Pricing tests` ``) are not in the
279
+ tree-sitter grammar: in a file that declares one, each backticked span with a space or punctuation is parsed as
280
+ a same-length plain identifier (`_` for every other byte), so byte offsets, and the names read from the
281
+ original source, stay as written."""
282
+ if b"`" not in src or not RAW_IDENT_DECL.search(src):
283
+ return src
284
+ return RAW_IDENT.sub(_raw_span, src)
285
+
286
+
287
+ SOURCE_LOCATION_DIRECTIVE = re.compile(rb"^[ \t]*#sourceLocation\s*\([^)\n]*\)[ \t]*$", re.M)
288
+ UNDERSCORE_MACRO = re.compile(rb"(?<![\w#])#_[A-Za-z]\w*\b")
289
+ IF_BLOCK = re.compile(rb"^[ \t]*#if\b[^\n]*\n(?:(?![ \t]*#if\b)[^\n]*\n)*?[ \t]*#endif\b[^\n]*$", re.M)
290
+ DIRECTIVE_LINE = re.compile(rb"^[ \t]*#(?:if|elseif|else|endif)\b[^\n]*$", re.M)
291
+ ATTRIBUTES_ONLY = re.compile(rb"\s*(?:@[\w.]+(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?\s*)+")
292
+
293
+
294
+ # `typealias Client = A.Client` / ` & B.Client`, `let total = base` / ` * word.count`: a binary operator that starts a
295
+ # continuation line (the grammar closes the statement at the line end, and a type alias then takes the enclosing type
296
+ # with it); the operator moves to the end of the line before, same byte length and line count
297
+ CONTINUATION = re.compile(rb"(?<=[\w)\]}?!>\"])([ \t]*)\n([ \t]*)(&&|\|\||\?\?|==|!=|<=|>=|\.\.<|\.\.\.|[&|+*/%<>-])([ \t]+)(?=\S)")
298
+
299
+
300
+ # `()` as a value (`.right(())`, `const(())`, `{ _ in () }`, `cond ? x : ()`) and as an empty associated-value
301
+ # pattern (`case .right():`); `@convention(c)` in a type: not in the grammar, rewritten to a same-length `0` / blanks
302
+ EMPTY_TUPLE_VALUE = re.compile(rb"(?:(?<=\()|(?<=,)|(?<=\bin)|(?<=\breturn)|(?<=\?\?)|(?<=[^:]:))([ \t]*)\(\)"
303
+ rb"(?=[ \t]*(?:[),}\]\n]|//))(?![ \t]*\)[ \t]*(?:->|throws|async|rethrows))")
304
+ EMPTY_CASE_PATTERN = re.compile(rb"(\bcase[ \t]+\.\w+)\(\)(?=[ \t]*[:,])")
305
+ CONVENTION = re.compile(rb"@convention\(\w+\)")
306
+ # `if let x = try? await f()`, `while try await g()`: `try` before `await` in a condition does not parse; `try` is blanked
307
+ TRY_AWAIT = re.compile(rb"(?<![\w.])try[?!]?(?=[ \t]+await\b)")
308
+ # `x as? String ?? ""`: a cast before `??` does not parse; the cast is blanked (`x ?? ""`)
309
+ CAST_COALESCE = re.compile(rb"(?<![\w.])as[?!]?[ \t]+(?:[\w.]+(?:<[^<>\n]*>)?|\[[^\[\]\n]*\])\??(?=[ \t]*\?\?)")
310
+
311
+
312
+ def _empty_tuple(m, src: bytes) -> bytes:
313
+ """`0 ` for a `()` value; unchanged inside generic arguments (`Tagged<((), email: ()), String>` is a type)."""
314
+ pre = src[src.rfind(b"\n", 0, m.start()) + 1:m.start()].replace(b"->", b"")
315
+ if pre.count(b"<") > pre.count(b">") or b"typealias" in pre:
316
+ return m.group(0)
317
+ return m.group(1) + b"0 "
318
+
319
+
320
+ def _continuation(m) -> bytes:
321
+ trail, indent, op, gap = m.group(1), m.group(2), m.group(3), m.group(4)
322
+ return b" " + op + trail + b"\n" + indent + gap[:-1] if gap else m.group(0)
323
+
324
+
325
+ def _blank(m) -> bytes:
326
+ return re.sub(rb"[^\n]", b" ", m.group(0))
327
+
328
+
329
+ def _attribute_block(m, src: bytes, blocks: list) -> bytes:
330
+ """`#if os(macOS)` / `@Test` / `#endif` above a declaration: the directive lines are blanked so the attribute
331
+ attaches to the declaration that follows; (first line, last line, `#if` line text) goes to `blocks`."""
332
+ block = m.group(0)
333
+ if not ATTRIBUTES_ONLY.fullmatch(DIRECTIVE_LINE.sub(b"", block)):
334
+ return block
335
+ first = src.count(b"\n", 0, m.start()) + 1
336
+ dirs = DIRECTIVE_LINE.findall(block)
337
+ blocks.append((first, first + block.count(b"\n"),
338
+ dirs[0].strip().decode("utf-8", "replace") if len(dirs) == 2 else None))
339
+ return DIRECTIVE_LINE.sub(_blank, block)
340
+
341
+
342
+ def _join_continuations(src: bytes) -> bytes:
343
+ """Apply CONTINUATION, except after a line that ends in a `//` comment (the operator would be commented out)."""
344
+ if b"\n" not in src:
345
+ return src
346
+ def sub(m):
347
+ line_start = src.rfind(b"\n", 0, m.start()) + 1
348
+ if b"//" in src[line_start:m.start()] or b"/*" in src[line_start:m.start()]:
349
+ return m.group(0)
350
+ return _continuation(m)
351
+ return CONTINUATION.sub(sub, src)
352
+
353
+
354
+ def _preprocess(src: bytes, blocks: list | None = None) -> tuple[bytes, list[str]]:
355
+ """Valid Swift the tree-sitter grammar does not parse, rewritten in place with the same byte length (offsets and
356
+ line numbers stay those of the file; names are read from the original source): raw identifiers, a
357
+ `#sourceLocation(...)` directive (blanked), a `#_sourceLocation`-style macro expression (a same-length `#line`),
358
+ and an `#if` block holding only attributes (its directive lines blanked, #73). Returns the source to parse and
359
+ what was rewritten."""
360
+ out, what = _raw_identifiers(src), []
361
+ if out is not src:
362
+ what.append("raw_identifiers")
363
+ if b"#sourceLocation" in out:
364
+ new = SOURCE_LOCATION_DIRECTIVE.sub(_blank, out)
365
+ if new != out:
366
+ out = new
367
+ what.append("source_location_directives")
368
+ if b"#_" in out:
369
+ new = UNDERSCORE_MACRO.sub(lambda m: b"#line" + b" " * (len(m.group(0)) - 5) if len(m.group(0)) >= 5
370
+ else m.group(0), out)
371
+ if new != out:
372
+ out = new
373
+ what.append("underscore_macros")
374
+ if b"()" in out:
375
+ new = EMPTY_CASE_PATTERN.sub(lambda m: m.group(1) + b" ", EMPTY_TUPLE_VALUE.sub(lambda m: _empty_tuple(m, out), out))
376
+ if new != out:
377
+ out = new
378
+ what.append("empty_tuples")
379
+ if b"@convention" in out:
380
+ out = CONVENTION.sub(_blank, out)
381
+ what.append("convention_attributes")
382
+ if b"await" in out:
383
+ new = TRY_AWAIT.sub(_blank, out)
384
+ if new != out:
385
+ out = new
386
+ what.append("try_await")
387
+ if b"??" in out:
388
+ new = CAST_COALESCE.sub(_blank, out)
389
+ if new != out:
390
+ out = new
391
+ what.append("cast_coalesce")
392
+ new = _join_continuations(out)
393
+ if new != out:
394
+ out = new
395
+ what.append("continuation_operators")
396
+ if b"#if" in out and b"@" in out:
397
+ found = [] if blocks is None else blocks
398
+ new = IF_BLOCK.sub(lambda m: _attribute_block(m, out, found), out)
399
+ if new != out:
400
+ out = new
401
+ what.append("attributes_in_if_blocks")
402
+ assert len(out) == len(src)
403
+ return out, what
404
+
405
+
406
+ _DEPLOY_SETTING = re.compile(r"\b(IPHONEOS|MACOSX|TVOS|WATCHOS|XROS)_DEPLOYMENT_TARGET\s*[=:]\s*[\"']?(\d+(?:\.\d+)*)")
407
+ _DEPLOY_SPM = re.compile(r"\.(iOS|macOS|tvOS|watchOS|visionOS|macCatalyst)\s*\(\s*(?:\.v(\d+(?:_\d+)*)|\"(\d+(?:\.\d+)*)\")")
408
+ _DEPLOY_YML = re.compile(r"(?m)^(\s*)deploymentTarget\s*:\s*\n((?:\1\s+\w+\s*:\s*[\"']?[\d.]+[\"']?\s*\n?)+)")
409
+ _SETTING_OS = {"IPHONEOS": "iOS", "MACOSX": "macOS", "TVOS": "tvOS", "WATCHOS": "watchOS", "XROS": "visionOS"}
410
+
411
+
412
+ def deployment_targets(root) -> dict:
413
+ """Lowest deployment target per OS (#100) from Xcode build settings (`IPHONEOS_DEPLOYMENT_TARGET` in .pbxproj /
414
+ .xcconfig / XcodeGen YAML), XcodeGen `deploymentTarget:` and SwiftPM `platforms: [.iOS(.v16)]`."""
415
+ import os
416
+ from ... import presets
417
+ skip = set(presets.values("swift", "skip_dirs", default=[]) or []) | {"build"}
418
+ out: dict = {}
419
+
420
+ def add(os_, v):
421
+ if os_ and v and (os_ not in out or tuple(int(x) for x in v.split(".")) < tuple(int(x) for x in out[os_].split("."))):
422
+ out[os_] = v
423
+ for dp, dns, fns in os.walk(root):
424
+ dns[:] = [d for d in dns if d not in skip and not d.startswith(".")]
425
+ for fn in fns:
426
+ if not (fn.endswith((".pbxproj", ".xcconfig", ".yml", ".yaml")) or fn == "Package.swift"):
427
+ continue
428
+ try:
429
+ txt = open(os.path.join(dp, fn), encoding="utf-8", errors="replace").read(2_000_000)
430
+ except OSError:
431
+ continue
432
+ if fn == "Package.swift":
433
+ for m in _DEPLOY_SPM.finditer(txt.split("targets:", 1)[0]):
434
+ add(m.group(1), (m.group(2) or "").replace("_", ".") or m.group(3))
435
+ continue
436
+ for m in _DEPLOY_SETTING.finditer(txt):
437
+ add(_SETTING_OS[m.group(1)], m.group(2))
438
+ if fn.endswith((".yml", ".yaml")):
439
+ for m in _DEPLOY_YML.finditer(txt):
440
+ for k, v in re.findall(r"(\w+)\s*:\s*[\"']?([\d.]+)", m.group(2)):
441
+ add(k if k in ("iOS", "macOS", "tvOS", "watchOS", "visionOS") else None, v.strip("."))
442
+ return out
443
+
444
+
445
+ class SFile:
446
+ def __init__(self, rel: str, src: bytes, tree):
447
+ self.rel, self.src, self.tree = rel, src, tree
448
+ self.test = bool(TEST_PATH.search(rel))
449
+ self.imports: set[str] = set()
450
+ self.attr_blocks: list = [] # attribute-only `#if` blocks blanked before parsing (_preprocess)
451
+
452
+
453
+ class SwiftPlugin(LanguagePlugin):
454
+ name = "swift"
455
+ mac = None # how the project builds for the Mac (xcode.apple_build), set per index
456
+
457
+ def detect(self, project: Project) -> bool:
458
+ self._files = source_files(project.root, project)
459
+ return bool(self._files)
460
+
461
+ def t(self, n) -> str:
462
+ return self.cur.src[n.start_byte:n.end_byte].decode("utf-8", "replace")
463
+
464
+ # ------------------------------------------------------------------ index
465
+ def index(self, project: Project, builder: GraphBuilder, frameworks) -> dict:
466
+ t0 = time.time()
467
+ p = parser()
468
+ self.b = builder
469
+ files = getattr(self, "_files", None)
470
+ if files is None:
471
+ files = source_files(project.root, project)
472
+ self.decls: dict[str, Decl] = {}
473
+ self._raw: dict[str, str] = {}
474
+ self._attr_raw: dict[str, dict] = {} # decl id -> {"Test" | "Suite": attribute source text}
475
+ self._overloads: dict[tuple, Decl] = {}
476
+ self._vmember: dict[tuple, str] = {}
477
+ self._qsup: dict[str, list] = defaultdict(list) # type fqn -> qualified supertype names written # (variant type id, member id without @line) -> its node id (#86)
478
+ self.by_name: dict[str, list[Decl]] = defaultdict(list)
479
+ self.types: dict[str, Decl] = {}
480
+ self.members: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list))
481
+ self.calls: list[tuple] = []
482
+ self.props: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list)) # type fqn -> name
483
+ self.prop_names: set[str] = set()
484
+ self.props_in: dict[str, list[Decl]] = defaultdict(list) # file -> property nodes
485
+ self.stored_names: set[str] = set() # names of plain stored properties anywhere in the project
486
+ self.stored_in: dict[str, set[str]] = defaultdict(set) # type fqn -> its stored property names
487
+ self.fields: dict[str, dict[str, str]] = defaultdict(dict) # type fqn -> stored instance property -> field id
488
+ self.field_names: set[str] = set()
489
+ self.mutating_names: set[str] = set()
490
+ self.branch_of: dict[tuple, dict] = {} # (owner, line, type name) -> enclosing branch of a construction
491
+ self.branch_at: dict[tuple, dict] = {} # (file, line, type name) -> the same, for the exact layer
492
+ self.values: dict[str, dict[str, str]] = defaultdict(dict) # type fqn ("" file level) -> case/constant -> id
493
+ self.globals_: dict[str, list] = {} # file-level constant name -> [(id, file, test, private)]
494
+ self.prop_refs: list[tuple] = [] # (owner, name, receiver | None, line, sf, decl, write)
495
+ self.sigs: dict[str, list] = defaultdict(list) # function / init node id -> [((label, optional), ...)]
496
+ self.http: list[dict] = []
497
+ self.navs: list[tuple] = [] # (owner, view type name, how, file, line)
498
+ # navigationDestination(for: T.self) { switch d { case .x: V() } }: T -> case (None: any) -> view names (#68)
499
+ self.dest_map: dict[str, dict] = defaultdict(lambda: defaultdict(list))
500
+ self.value_navs: list[tuple] = [] # (owner, type name | None, case, how, file, line)
501
+ self.handler_refs: list[tuple] = [] # (route id, method name, type fqn, file, line)
502
+ self.st = defaultdict(int)
503
+ from ...xcode import apple_build
504
+ try: # how the project builds for the Mac: AppKit target, Catalyst, both or neither (#74)
505
+ self.mac = apple_build(project.root)["mac"]
506
+ except Exception: # noqa: BLE001 (a build file cg cannot read never fails the index)
507
+ self.mac = None
508
+ from .packages import SwiftPM
509
+ try: # SwiftPM modules: what a package target may name (#90)
510
+ self.spm = SwiftPM(project.root, list(files))
511
+ except Exception: # noqa: BLE001
512
+ self.spm = None
513
+ self._at = None
514
+ if self.spm:
515
+ self.st["swiftpm_targets"] = len(self.spm.targets)
516
+ sfiles, failed = [], []
517
+ errs: dict[str, list] = {}
518
+ for rel in files:
519
+ try:
520
+ src = (project.root / rel).read_bytes()
521
+ except OSError:
522
+ failed.append(rel)
523
+ continue
524
+ blocks: list = []
525
+ psrc, what = _preprocess(src, blocks)
526
+ for w in what:
527
+ self.st[f"files_with_{w}"] += 1
528
+ tree = p.parse(psrc)
529
+ if tree.root_node.has_error:
530
+ self.st["files_with_syntax_errors"] += 1
531
+ errs[rel] = tree_spans(tree.root_node)
532
+ sfiles.append(SFile(rel, src, tree))
533
+ sfiles[-1].attr_blocks = blocks
534
+ self._fd: dict[str, list[Decl]] = defaultdict(list)
535
+ self._avail_regions: dict[str, list] = {}
536
+ self.deploy = deployment_targets(project.root)
537
+ if self.deploy:
538
+ self.st["deployment_targets"] = dict(self.deploy)
539
+ for sf in sfiles: # pass 1: declarations
540
+ self.cur = sf
541
+ for c in sf.tree.root_node.children:
542
+ if c.type == "import_declaration":
543
+ sf.imports.add(self.t(c).split()[-1])
544
+ if sf.imports & TEST_IMPORTS: # an Xcode test target folder with any name
545
+ sf.test = True
546
+ self._decls(sf.tree.root_node, sf, None)
547
+ for d in self.decls.values():
548
+ self._fd[d.file].append(d)
549
+ self._xctest_methods()
550
+ self._moya_targets(sfiles)
551
+ self._fluent_models(sfiles)
552
+ for sf in sfiles: # pass 2: references and framework facts
553
+ self.cur = sf
554
+ fid = self.b.add_node("file", f"swift:{sf.rel}", name=sf.rel, file=sf.rel, line=1, lang="swift",
555
+ attrs={"test": True} if sf.test else {})
556
+ self._refs(sf.tree.root_node, sf, fid, None, {})
557
+ self._directives(sf)
558
+ self._available(sf)
559
+ self._resolve_calls()
560
+ self._resolve_props()
561
+ self._at = None
562
+ self._hierarchy()
563
+ self._fluent_migrations(sfiles)
564
+ self._moya_endpoints()
565
+ from .baseurl import collect
566
+ self.bases = collect(project.root, {sf.rel: sf.src.decode("utf-8", "replace") for sf in sfiles
567
+ if not sf.test and (b"http" in sf.src or b"InfoDictionary" in sf.src)})
568
+ self._emit_http()
569
+ self._link_navs()
570
+ for rid, mname, tfq, file, line in self.handler_refs:
571
+ t = self.types.get(tfq)
572
+ ms = self._member(t, mname) if t else []
573
+ if ms:
574
+ self.b.add_edge(rid, ms[0].id, "ROUTES_TO", file, line, HEURISTIC)
575
+ self.b.nodes[rid].attrs.setdefault("handler", ms[0].fqn)
576
+ else:
577
+ self.st["routes_unresolved_handler"] += 1
578
+ self.file_report = {"seen": [sf.rel for sf in sfiles] + failed, "parse_failed": failed, "syntax_errors": errs}
579
+ mode = self._exact(project, [sf.rel for sf in sfiles])
580
+ self._apply_available()
581
+ st = dict(self.st)
582
+ st.update({"mode": mode, "files": len(sfiles), "declarations": len(self.decls),
583
+ "source_files": sum(1 for sf in sfiles if not re.match(r"(.*/)?Package(@swift-[\d.]+)?\.swift$", sf.rel)),
584
+ "seconds": round(time.time() - t0, 2)})
585
+ return st
586
+
587
+ def _exact(self, project: Project, files: list[str]) -> str:
588
+ """Exact layer from the Swift index store (exact.py); the heuristic graph stays when there is none or it
589
+ cannot be read."""
590
+ from . import exact
591
+ t0 = time.time()
592
+ try:
593
+ store, info = exact.find_store(project, files)
594
+ except Exception as e: # noqa: BLE001 - a toolchain problem must not lose the heuristic graph
595
+ store, info = None, {"status": f"index store lookup failed: {e}"}
596
+ mode = "heuristic"
597
+ if store is not None:
598
+ try:
599
+ if exact.ExactLayer(self).apply(store, info["lib"], project.root, files, self.st):
600
+ mode = "indexstore"
601
+ else:
602
+ info["status"] = "the index store has no units for this project's files"
603
+ except Exception as e: # noqa: BLE001
604
+ info["status"] = f"index store import failed: {type(e).__name__}: {e}"
605
+ info["seconds"] = round(time.time() - t0, 2)
606
+ self.st["index"] = info
607
+ return mode
608
+
609
+ # ------------------------------------------------------------------ pass 1
610
+ def _opens_branch(self, n) -> bool:
611
+ """Is `n` the first declaration of a `#if` / `#elseif` / `#else` branch (comments aside)? A conditional
612
+ definition next to an unconditional one of that name is its own node too (Alamofire's `init(_:)` and the
613
+ per-platform `init()`s)."""
614
+ p = n.prev_sibling
615
+ while p is not None:
616
+ if p.type == "directive":
617
+ return not self.t(p).lstrip().startswith("#endif")
618
+ if p.is_named and p.type not in ("comment", "multiline_comment"):
619
+ return False
620
+ p = p.prev_sibling
621
+ return False
622
+
623
+ def _other_branch(self, n, prev_line: int) -> bool:
624
+ """Does `n` sit in another `#if` / `#elseif` / `#else` branch than the declaration at `prev_line` before it?
625
+ It does when a `#else` / `#elseif` of the block around that declaration comes between them, or when both are
626
+ in conditional blocks (`#if A ... #endif #if B ... #endif`). Comments, imports and other declarations in
627
+ between do not matter; an unconditional overload after an unrelated `#if DEBUG ... #endif` is no variant."""
628
+ a = n
629
+ while a.parent is not None and not (a.parent.start_point[0] + 1 <= prev_line <= a.parent.end_point[0] + 1):
630
+ a = a.parent # the ancestor of `n` that is a sibling of the earlier declaration
631
+ if a.parent is None:
632
+ return False
633
+ depth, exited = 0, False
634
+ for p in a.parent.children:
635
+ if p.start_point[0] + 1 < prev_line or p.type != "directive":
636
+ if p.id == a.id:
637
+ break
638
+ continue
639
+ t = self.t(p).lstrip()
640
+ if t.startswith("#if"):
641
+ depth += 1
642
+ elif t.startswith("#endif"):
643
+ if depth:
644
+ depth -= 1
645
+ else:
646
+ exited = True
647
+ elif t.startswith("#else") and depth == 0: # `#else` and `#elseif`
648
+ return True
649
+ return exited and depth > 0
650
+
651
+ def _branch_variant(self, prev: Decl, sf: SFile, n, cls: Decl | None) -> bool:
652
+ """Is `n`, declared with the same name as `prev`, a separate definition for another `#if` branch? It is when
653
+ the two sit in different branches of the same file (`_other_branch`), when `n` opens a branch
654
+ (`_opens_branch`), or when `n` is a member of a type that is itself a per-branch variant
655
+ (`#if os(macOS) struct T { func f() } #else struct T { func f() } #endif`, #86)."""
656
+ if prev.file != sf.rel:
657
+ return False
658
+ if self._opens_branch(n) or self._other_branch(n, prev.line):
659
+ return True
660
+ return cls is not None and "@" in cls.id and prev.owner != cls.id
661
+
662
+ @staticmethod
663
+ def _owner(cls: Decl | None) -> str | None:
664
+ return cls.id if cls is not None and "@" in cls.id else None
665
+
666
+ def _prev(self, did: str, cls: Decl | None) -> Decl:
667
+ """The declaration `did` names so far, inside the variant type `cls` when it is one (its own overloads)."""
668
+ own = self._vmember.get((cls.id, did)) if cls is not None else None
669
+ return self.decls[own] if own else self.decls[did]
670
+
671
+ def _mods(self, n) -> tuple[list, set]:
672
+ attrs, mods = [], set()
673
+ self._raw = {}
674
+ for c in n.children:
675
+ if c.type == "modifiers":
676
+ for m in c.children:
677
+ if m.type == "attribute":
678
+ mm = re.match(r"@`?([\w.]+)`?", self.t(m))
679
+ if mm:
680
+ attrs.append(mm.group(1).split(".")[-1])
681
+ if attrs[-1] in ("Test", "Suite"):
682
+ self._raw.setdefault(attrs[-1], self.t(m))
683
+ else:
684
+ mods.update(self.t(m).split())
685
+ return attrs, mods
686
+
687
+ def _name(self, n) -> str | None:
688
+ x = n.child_by_field_name("name")
689
+ return self.t(x).split("<")[0].strip() if x is not None else None
690
+
691
+ def _type_name(self, n) -> str | None:
692
+ if n is None:
693
+ return None
694
+ m = re.match(r"\s*(?:some\s+|any\s+)?\[?([A-Za-z_][\w.]*)", self.t(n))
695
+ return m.group(1).split(".")[-1] if m else None
696
+
697
+ @staticmethod
698
+ def _orphans(n) -> dict:
699
+ """Declarations tree-sitter put after a type whose body it closed early (a MISSING `}`, e.g. after a line the
700
+ grammar does not know), up to the stray `}` that really closes it (an ERROR at the same level), or the end:
701
+ node id -> the type declaration node they belong to (#73)."""
702
+ out = {}
703
+ kids = n.children
704
+ i = 0
705
+ while i < len(kids):
706
+ c = kids[i]
707
+ body = next((x for x in c.children if x.type in ("class_body", "enum_class_body", "protocol_body")), None) \
708
+ if c.type in TYPE_DECLS else None
709
+ if body is None or not body.children or not body.children[-1].is_missing:
710
+ i += 1
711
+ continue
712
+ j = i + 1
713
+ while j < len(kids) and not (kids[j].type == "ERROR" and
714
+ kids[j].children and kids[j].children[0].type == "}"):
715
+ if kids[j].is_named and kids[j].type not in ("comment", "multiline_comment"):
716
+ out[kids[j].id] = c
717
+ j += 1
718
+ if j < len(kids):
719
+ out[kids[j].id] = c # the closing `}`: marks where the type ends
720
+ i = j + 1
721
+ return out
722
+
723
+ def _adopter(self, t, sf: SFile, cls: Decl | None) -> Decl | None:
724
+ nm = self._name(t)
725
+ if not nm:
726
+ return None
727
+ head = self.t(t).split("{", 1)[0]
728
+ if re.search(r"\bextension\b", head):
729
+ return self.types.get(nm)
730
+ return self.types.get(f"{cls.fqn}.{nm}" if cls and cls.kind == "class" else nm)
731
+
732
+ def _decls(self, n, sf: SFile, cls: Decl | None):
733
+ adopt = self._orphans(n) if n.children and getattr(n, "has_error", False) else {}
734
+ for c in n.children:
735
+ if c.id in adopt:
736
+ d = self._adopter(adopt[c.id], sf, cls)
737
+ if d is not None and d.file == sf.rel:
738
+ end = c.end_point[0] + 1
739
+ if end > d.end: # the type reaches its real closing brace
740
+ d.end = self.b.nodes[d.id].end_line = end
741
+ if c.type != "ERROR":
742
+ one = SimpleNamespace(children=[c])
743
+ self._props(one, d, sf)
744
+ self._decls(one, sf, d)
745
+ self.st["declarations_recovered_into_type"] += 1 if c.type in (
746
+ "function_declaration", "init_declaration", "property_declaration") + TYPE_DECLS else 0
747
+ continue
748
+ ty = c.type
749
+ if ty in TYPE_DECLS:
750
+ nm = self._name(c)
751
+ if not nm:
752
+ continue
753
+ head = self.t(c).split("{", 1)[0]
754
+ kw = re.match(r"\s*(?:@[\w.]+(?:\((?:[^()]|\([^()]*\))*\))?\s*|\w+\s+)*?(class|struct|enum|actor|extension|protocol)\b",
755
+ head)
756
+ kw = kw.group(1) if kw else ("protocol" if ty == "protocol_declaration" else "class")
757
+ attrs, mods = self._mods(c)
758
+ supers = [self._type_name(x) for x in c.children if x.type == "inheritance_specifier"]
759
+ supers = [s for s in supers if s]
760
+ fq = f"{cls.fqn}.{nm}" if cls and kw != "extension" else nm
761
+ for x in c.children: # `: StatusEditor.AutocompleteService.Client`: the qualified name
762
+ if x.type == "inheritance_specifier":
763
+ q = re.match(r"\s*(?:some\s+|any\s+)?([A-Za-z_][\w.]*)", self.t(x))
764
+ if q and "." in q.group(1) and q.group(1) not in self._qsup[fq]:
765
+ self._qsup[fq].append(q.group(1))
766
+ body = next((x for x in c.children if x.type in ("class_body", "enum_class_body", "protocol_body")), None)
767
+ if kw == "extension":
768
+ base = self.types.get(nm)
769
+ if base is None:
770
+ base = Decl(f"class:{nm}", "class", nm, nm, sf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
771
+ None, [], [], {"extension-only"}, test=sf.test)
772
+ self._add(base, "extension")
773
+ base.supers += [s for s in supers if s not in base.supers]
774
+ self.st["extensions"] += 1
775
+ if body is not None:
776
+ self._props(body, base, sf)
777
+ self._decls(body, sf, base)
778
+ continue
779
+ d = self.types.get(fq)
780
+ if d is not None and "extension-only" in d.modifiers: # extension seen before the type
781
+ d.modifiers.discard("extension-only")
782
+ d.file, d.line, d.end = sf.rel, c.start_point[0] + 1, c.end_point[0] + 1
783
+ d.supers = supers + [s for s in d.supers if s not in supers]
784
+ d.attributes = attrs
785
+ n0 = self.b.nodes[d.id]
786
+ n0.file, n0.line, n0.end_line = d.file, d.line, d.end
787
+ n0.attrs["swift_kind"] = kw
788
+ if d.test and "Suite" in attrs:
789
+ self._attr_raw[d.id] = self._raw
790
+ self._swift_testing(n0, d, "Suite")
791
+ else:
792
+ did = f"class:{fq}"
793
+ if d is not None and self._branch_variant(d, sf, c, cls):
794
+ did = f"{did}@{c.start_point[0] + 1}" # one definition per `#if` branch (#86)
795
+ d = Decl(did, "class", nm, fq, sf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
796
+ cls.fqn if cls else None, supers, attrs, mods, test=sf.test, owner=self._owner(cls))
797
+ self._add(d, kw)
798
+ if body is not None:
799
+ self._props(body, d, sf)
800
+ self._decls(body, sf, d)
801
+ elif ty in ("function_declaration", "init_declaration", "protocol_function_declaration"):
802
+ nm = "init" if ty == "init_declaration" else self._name(c)
803
+ if not nm:
804
+ continue
805
+ attrs, mods = self._mods(c)
806
+ kind = "method" if cls else "function"
807
+ fq = f"{cls.fqn}.{nm}" if cls else nm
808
+ params = {}
809
+ sig = []
810
+ kids = list(c.children)
811
+ for i, x in enumerate(kids):
812
+ if x.type == "parameter":
813
+ names = [self.t(y) for y in x.children if y.type == "simple_identifier"]
814
+ tn = self._type_name(next((y for y in x.children if y.is_named and y.type not in
815
+ ("simple_identifier",)), None))
816
+ if names and tn:
817
+ params[names[-1]] = tn
818
+ # optional at a call site: a default value, a closure (trailing-closure syntax), variadic
819
+ opt = (i + 1 < len(kids) and kids[i + 1].type == "=") or any(
820
+ y.type in ("function_type", "...") for y in x.children) or "->" in self.t(x)
821
+ if names:
822
+ sig.append((names[0], opt))
823
+ did = f"{kind}:{fq}"
824
+ if did in self.decls and "property" in self.decls[did].modifiers:
825
+ did = f"{did}~func" # `var label { }` declared first, then `func label(for:)`
826
+ if did in self.decls and _static(self.decls[did]) != bool(STATIC_MODS & mods):
827
+ # `func weight(forExtraIndex:)` and `static func weight(from:to:)`: two nodes; the one declared
828
+ # second carries `~static` / `~instance` (like `~2` for a second TypeScript declaration);
829
+ # overloads of one static-ness still share a node
830
+ did = f"{did}~{'static' if STATIC_MODS & mods else 'instance'}"
831
+ if did in self.decls:
832
+ prev = self._prev(did, cls)
833
+ if not self._branch_variant(prev, sf, c, cls): # overloads share one node
834
+ prev.types.update(params)
835
+ self.sigs[did].append(tuple(sig))
836
+ self._overloads[(sf.rel, c.start_point[0] + 1, nm)] = prev # its body's calls are prev's
837
+ continue
838
+ did = f"{did}@{c.start_point[0] + 1}" # per-platform definition (#if os)
839
+ self.sigs[did].append(tuple(sig))
840
+ d = Decl(did, kind, nm, fq, sf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
841
+ cls.fqn if cls else None, [], attrs, mods, params, sf.test, owner=self._owner(cls))
842
+ self._add(d, "protocol requirement" if ty == "protocol_function_declaration" else kind)
843
+ elif ty == "property_declaration" and cls is not None:
844
+ nm = self._name(c)
845
+ if nm == "body" and any(x.type == "computed_property" for x in c.children):
846
+ did = f"method:{cls.fqn}.body"
847
+ if did in self.decls and self._branch_variant(self._prev(did, cls), sf, c, cls):
848
+ did = f"{did}@{c.start_point[0] + 1}"
849
+ d = Decl(did, "method", "body", f"{cls.fqn}.body", sf.rel,
850
+ c.start_point[0] + 1, c.end_point[0] + 1, cls.fqn, [], [], set(), test=sf.test,
851
+ owner=self._owner(cls))
852
+ self._add(d, "view body")
853
+ elif nm:
854
+ self._prop_decl(c, nm, sf, cls)
855
+ elif ty == "enum_entry" and cls is not None:
856
+ for x in c.children:
857
+ if x.type == "simple_identifier":
858
+ self._value(cls, "enum_case", self.t(x), sf, x)
859
+ elif ty == "property_declaration" and cls is None and self._top_let(c) and \
860
+ not re.match(r"Package(@swift-[\d.]+)?\.swift$", os.path.basename(sf.rel)):
861
+ nm = self._name(c)
862
+ if nm:
863
+ self._value(None, "constant", nm, sf, c)
864
+ elif ty == "protocol_property_declaration" and cls is not None:
865
+ nm = (self._name(c) or "").split()[-1:] # `var isShown: Bool { get }`: a conformer has it (#86)
866
+ nm = nm[0] if nm else None
867
+ req = self.b.nodes[cls.id].attrs.setdefault("property_requirements", [])
868
+ if nm and nm not in req and len(req) < 64:
869
+ req.append(nm)
870
+ elif ty not in ("lambda_literal", "statements", "function_body"):
871
+ self._decls(c, sf, cls)
872
+
873
+ @staticmethod
874
+ def _top_let(c) -> bool:
875
+ """A file-level `let` (not `var`) binding a single name: a constant."""
876
+ vb = next((x for x in c.children if x.type == "value_binding_pattern"), None)
877
+ return vb is not None and vb.text.strip() == b"let" and sum(x.type == "pattern" for x in c.children) == 1
878
+
879
+ def _value(self, cls: Decl | None, kind: str, nm: str, sf: SFile, n):
880
+ """An enum case (`enum_case:<Type>.<case>`) or a constant (`constant:<Type>.<name>`, `constant:<name>`
881
+ for a file-level `let`): a node under its type that USES_VALUE edges point at (#84). Not a call target,
882
+ so no CALLS edge changes."""
883
+ fq = f"{cls.fqn}.{nm}" if cls else nm
884
+ line = n.start_point[0] + 1
885
+ key = fq
886
+ _attrs, mods = self._mods(n)
887
+ private = cls is None and bool(mods & {"private", "fileprivate"})
888
+ if private:
889
+ key = f"{fq}#{sf.rel}" # a file's own `private let ray`
890
+ prev = self.b.nodes.get(f"{kind}:{key}")
891
+ if prev is not None and (prev.file, prev.line) != (sf.rel, line):
892
+ if cls is None and prev.file != sf.rel:
893
+ key = f"{fq}#{sf.rel}" # another file's (another module's) `let jsonDecoder`: a symbol of its own
894
+ if f"{kind}:{key}" in self.b.nodes:
895
+ key = f"{key}@{line}"
896
+ else:
897
+ key = f"{fq}@{line}" # another `#if` branch's definition: the platform pass links the two
898
+ nid = self.b.add_node(kind, key, name=nm, fqn=fq, file=sf.rel, line=line, end_line=n.end_point[0] + 1,
899
+ module=cls.fqn if cls else None, lang="swift",
900
+ attrs={"test": True} if sf.test else {})
901
+ if cls is not None:
902
+ self.b.add_edge(cls.id, nid, "CONTAINS", sf.rel, line, EXACT)
903
+ self.values[cls.fqn].setdefault(nm, nid)
904
+ else:
905
+ self.globals_.setdefault(nm, []).append((nid, sf.rel, sf.test, private))
906
+ self.values[""].setdefault(nm, nid)
907
+
908
+ def _props(self, body, d: Decl, sf: SFile):
909
+ for x in body.children:
910
+ if x.type != "property_declaration":
911
+ continue
912
+ nm = self._name(x)
913
+ ta = next((y for y in x.children if y.type == "type_annotation"), None)
914
+ tn = self._type_name(ta.child_by_field_name("name") if ta is not None else None)
915
+ if tn is None:
916
+ m = re.search(r"=\s*([A-Z]\w*)\s*(?:<[^=]*?>)?\s*[.(]", self.t(x)) # Gate() / Gate<Image>()
917
+ tn = m.group(1) if m else None
918
+ if nm and tn:
919
+ d.types[nm] = tn
920
+
921
+ def _field(self, cls: Decl, nm: str, sf: SFile, c, attrs: list):
922
+ """A stored instance property (`var count = 0`, `@State private var value`, `@Published var items`) is a
923
+ `field:<Type>.<name>` node (#88): reads and writes of it are READS_PROP / WRITES_PROP edges, for `cg readers` /
924
+ `cg writers Type.prop`. Its property wrapper, if any, is attrs.wrapper."""
925
+ if nm in self.fields.get(cls.fqn, {}):
926
+ return
927
+ kw = next((self.t(x).split()[0] for x in c.children if x.type == "value_binding_pattern" and self.t(x).strip()), "var")
928
+ a = {"property": "stored", "binding": "let" if kw == "let" else "var"}
929
+ wrappers = [x for x in attrs if x not in ("MainActor", "objc", "IBOutlet", "IBInspectable", "available", "nonobjc")]
930
+ if wrappers:
931
+ a["wrapper"] = wrappers[0]
932
+ if wrappers[0] in ("AppStorage", "SceneStorage"): # the UserDefaults / scene key it persists under
933
+ m = re.search(r'@(?:AppStorage|SceneStorage)\s*\(\s*"([^"]+)"', self.t(c))
934
+ if m:
935
+ a["key"] = m.group(1)
936
+ if sf.test:
937
+ a["test"] = True
938
+ fid = self.b.add_node("field", f"{cls.fqn}.{nm}", name=nm, fqn=f"{cls.fqn}.{nm}", file=sf.rel,
939
+ line=c.start_point[0] + 1, end_line=c.end_point[0] + 1,
940
+ module=getattr(self.b.nodes.get(cls.id), "module", None), lang="swift", attrs=a)
941
+ self.b.add_edge(cls.id, fid, "CONTAINS", sf.rel, c.start_point[0] + 1, EXACT)
942
+ self.fields[cls.fqn][nm] = fid
943
+ self.field_names.add(nm)
944
+ if wrappers: # `_value = State(initialValue: x)`: the wrapper's storage is the same property
945
+ self.fields[cls.fqn]["_" + nm] = fid
946
+ self.field_names.add("_" + nm)
947
+ self.st["stored_property_nodes"] += 1
948
+
949
+ def _prop_decl(self, c, nm: str, sf: SFile, cls: Decl):
950
+ """A computed property, a stored property with `willSet` / `didSet`, or a `lazy var` with an initializer
951
+ becomes a node (`method:<Type>.<name>`, attrs.property, #72): the calls in its body come from it, and reads
952
+ (writes, for observers) of it are CALLS edges to it. A plain stored instance property is a `field:` node with
953
+ READS_PROP / WRITES_PROP edges instead (#88)."""
954
+ comp = next((x for x in c.children if x.type == "computed_property"), None)
955
+ obs = next((x for x in c.children if x.type == "willset_didset_block"), None)
956
+ attrs, mods = self._mods(c)
957
+ lazy = "lazy" in mods and any(x.type == "=" for x in c.children)
958
+ if not (comp or obs or lazy):
959
+ if mods & {"static", "class"}:
960
+ self._value(cls, "constant", nm, sf, c) # `static let shared = …` (#84)
961
+ elif "extension-only" not in cls.modifiers:
962
+ self._field(cls, nm, sf, c, attrs)
963
+ self.stored_names.add(nm)
964
+ self.stored_in[cls.fqn].add(nm) # hides a protocol extension's default of that name
965
+ return
966
+ fq = f"{cls.fqn}.{nm}"
967
+ did = f"method:{fq}"
968
+ line = c.start_point[0] + 1
969
+ if did in self.decls:
970
+ prev = self._prev(did, cls)
971
+ if "property" not in prev.modifiers:
972
+ did = f"{did}~property" # a `func label(for:)` declared first
973
+ elif self._branch_variant(prev, sf, c, cls):
974
+ did = f"{did}@{line}" # one definition per `#if` branch
975
+ else:
976
+ return
977
+ ranges = []
978
+ for blk in (comp, obs):
979
+ for x in (blk.children if blk is not None else ()):
980
+ if x.type in ACCESSOR_CLAUSES:
981
+ ranges.append((x.start_point[0] + 1, x.end_point[0] + 1, ACCESSOR_CLAUSES[x.type]))
982
+ how = "computed" if comp is not None else "observed" if obs is not None else "lazy"
983
+ mods = set(mods) | {"property", how}
984
+ d = Decl(did, "method", nm, fq, sf.rel, line, c.end_point[0] + 1, cls.fqn, [], attrs, mods, {}, sf.test, ranges,
985
+ self._owner(cls))
986
+ self._add(d, {"computed": "computed property", "observed": "property observers", "lazy": "lazy property"}[how],
987
+ index=False)
988
+ n = self.b.nodes[d.id]
989
+ n.attrs["property"] = how
990
+ if ranges:
991
+ n.attrs["accessors"] = [r[2] for r in ranges]
992
+ self.props[cls.fqn][nm].append(d)
993
+ self.props_in[sf.rel].append(d)
994
+ self.prop_names.add(nm)
995
+ self.st[f"properties_{how}"] += 1
996
+
997
+ def _add(self, d: Decl, display_kind: str, index: bool = True):
998
+ attrs = {"swift_kind": display_kind}
999
+ if d.test:
1000
+ attrs["test"] = True
1001
+ for a in ("static", "override", "async", "mutating", "private"):
1002
+ if a in d.modifiers:
1003
+ attrs[a] = True
1004
+ if "mutating" in d.modifiers and d.kind == "method":
1005
+ self.mutating_names.add(d.name) # `basket.add(x)` on a stored struct value writes it (#88)
1006
+ if d.attributes:
1007
+ attrs["attributes"] = d.attributes[:12]
1008
+ mod = d.cls or None
1009
+ self.b.add_node(d.kind, d.id.split(":", 1)[1], name=d.name, fqn=d.fqn, file=d.file, line=d.line,
1010
+ end_line=d.end, module=mod, lang="swift", attrs=attrs)
1011
+ n = self.b.nodes[d.id]
1012
+ if self._raw and ("Test" in d.attributes or "Suite" in d.attributes):
1013
+ self._attr_raw[d.id] = self._raw
1014
+ if d.kind == "class" and "main" in d.attributes:
1015
+ n.entry_kind = "main"
1016
+ self.st["entries_main"] += 1
1017
+ if d.test and d.kind in ("function", "method") and "Test" in d.attributes:
1018
+ self._swift_testing(n, d, "Test")
1019
+ elif d.test and d.kind == "class" and "Suite" in d.attributes:
1020
+ self._swift_testing(n, d, "Suite")
1021
+ self.decls[d.id] = d
1022
+ if d.owner:
1023
+ self._vmember.setdefault((d.owner, d.id.split("@", 1)[0]), d.id)
1024
+ if index: # a property node is not a call target (`_prop_targets` resolves reads of it)
1025
+ self.by_name[d.name].append(d)
1026
+ if d.kind == "class":
1027
+ self.types.setdefault(d.fqn, d)
1028
+ if d.cls:
1029
+ if index:
1030
+ self.members[d.cls][d.name].append(d)
1031
+ self.b.add_edge(d.owner or f"class:{d.cls}", d.id, "CONTAINS", d.file, d.line, EXACT)
1032
+
1033
+ def _swift_testing(self, n, d: Decl, attr: str):
1034
+ """Swift Testing: an `@Test` function / method is one test case (a parameterized `@Test(arguments:)` too), an
1035
+ `@Suite` type a suite; the display name, tags and traits come from the attribute's arguments."""
1036
+ raw = self._attr_raw.get(d.id, {}).get(attr, "")
1037
+ if attr == "Test":
1038
+ n.entry_kind = "test"
1039
+ n.attrs["framework"] = "swift-testing"
1040
+ if d.cls:
1041
+ n.attrs["suite"] = d.cls
1042
+ if re.search(r"\barguments\s*:", raw):
1043
+ n.attrs["parameterized"] = True
1044
+ self.st["swift_testing_tests"] += 1
1045
+ else:
1046
+ n.attrs["suite"] = True
1047
+ self.st["swift_testing_suites"] += 1
1048
+ m = re.match(r'@(?:Testing\.)?\w+\s*\(\s*"((?:[^"\\]|\\.)*)"', raw)
1049
+ if m:
1050
+ n.attrs["display_name"] = m.group(1)
1051
+ tags = [t for grp in re.findall(r"\.tags\(([^)]*)\)", raw) for t in re.findall(r"\.?(\w+)", grp)]
1052
+ if tags:
1053
+ n.attrs["tags"] = tags[:12]
1054
+ traits = sorted(set(re.findall(r"\.(disabled|enabled|bug|timeLimit|serialized)\b", raw)))
1055
+ if traits:
1056
+ n.attrs["traits"] = traits
1057
+
1058
+ def _xctest_methods(self):
1059
+ """XCTest: a `test*` instance method without parameters on a class whose superclass chain reaches XCTestCase
1060
+ (directly, through a project base class, or through an external `*TestCase` base)."""
1061
+ memo: dict[str, bool] = {}
1062
+
1063
+ def is_case(fq: str, depth=0) -> bool:
1064
+ if fq in memo:
1065
+ return memo[fq]
1066
+ memo[fq] = False
1067
+ d = self.types.get(fq) or self.types.get(fq.rsplit(".", 1)[-1])
1068
+ ok = False
1069
+ if d is not None and depth < 12:
1070
+ for s in d.supers:
1071
+ if s == "XCTestCase" or s.endswith("TestCase") or (s in self.types and is_case(s, depth + 1)):
1072
+ ok = True
1073
+ break
1074
+ memo[fq] = ok
1075
+ return ok
1076
+
1077
+ for d in self.decls.values():
1078
+ if not (d.test and d.kind == "method" and d.name.startswith("test") and d.cls) or "static" in d.modifiers:
1079
+ continue
1080
+ n = self.b.nodes.get(d.id)
1081
+ if n is None or n.entry_kind == "test" or any(self.sigs.get(d.id, [()])[0]):
1082
+ continue
1083
+ if is_case(d.cls):
1084
+ n.entry_kind = "test"
1085
+ n.attrs["framework"] = "xctest"
1086
+ self.st["xctest_tests"] += 1
1087
+
1088
+ # ------------------------------------------------------------------ pass 2
1089
+ def _decl_at(self, sf: SFile, n, kinds, name=None) -> Decl | None:
1090
+ line = n.start_point[0] + 1
1091
+ nm = name or self._name(n)
1092
+ for d in self._fd.get(sf.rel, ()):
1093
+ if d.line == line and d.kind in kinds and d.name == nm:
1094
+ return d
1095
+ ov = self._overloads.get((sf.rel, line, nm))
1096
+ if ov is not None and ov.kind in kinds:
1097
+ return ov
1098
+ if name and kinds == ("class",):
1099
+ d = self.types.get(name)
1100
+ return d
1101
+ return None
1102
+
1103
+ def _refs(self, n, sf: SFile, owner: str, decl: Decl | None, ctx: dict):
1104
+ adopt = self._orphans(n) if n.children and getattr(n, "has_error", False) else {}
1105
+ for c in n.children:
1106
+ if c.id in adopt and c.type != "ERROR":
1107
+ d = self._adopter(adopt[c.id], sf, decl if decl is not None and decl.kind == "class" else None)
1108
+ if d is not None:
1109
+ self._refs(SimpleNamespace(children=[c]), sf, d.id, d, ctx)
1110
+ continue
1111
+ ty = c.type
1112
+ if self.values and ty in VALUE_SITES:
1113
+ self._value_refs(c, sf, owner, decl)
1114
+ if ty == "navigation_expression" or (sf.test and ty == "line_string_literal"):
1115
+ self._preview_ref(c, sf, owner)
1116
+ if ty in ("function_declaration", "init_declaration"):
1117
+ d = self._decl_at(sf, c, ("function", "method"), "init" if ty == "init_declaration" else None)
1118
+ if d is not None:
1119
+ self._fn_http(c, sf, d)
1120
+ self._refs(c, sf, d.id, d, {"routers": {}})
1121
+ continue
1122
+ elif ty in TYPE_DECLS:
1123
+ nm = self._name(c)
1124
+ d = self.types.get(nm) if nm else None
1125
+ inner = next((x for x in self._fd.get(sf.rel, ()) if x.kind == "class" and x.name == nm
1126
+ and x.line == c.start_point[0] + 1), None)
1127
+ d = inner or d
1128
+ self._refs(c, sf, d.id if d else owner, d or decl, ctx)
1129
+ continue
1130
+ elif ty == "property_declaration" and decl is not None and decl.kind == "class" and self._name(c) == "body" \
1131
+ and f"method:{decl.fqn}.body" in self.decls:
1132
+ b = self.decls[f"method:{decl.fqn}.body"]
1133
+ self._refs(c, sf, b.id, b, ctx)
1134
+ continue
1135
+ elif ty == "property_declaration" and decl is not None and decl.kind == "class" and self.prop_names:
1136
+ pd = self._decl_at(sf, c, ("method",))
1137
+ if pd is not None and "property" in pd.modifiers:
1138
+ self._refs(c, sf, pd.id, pd, ctx)
1139
+ continue
1140
+ elif ty == "navigation_expression" and (self.prop_names or self.field_names):
1141
+ self._prop_nav(c, sf, owner, decl)
1142
+ elif ty == "simple_identifier" and (self.prop_names or self.field_names):
1143
+ self._prop_bare(c, sf, owner, decl)
1144
+ elif ty == "call_expression":
1145
+ if self._call(c, sf, owner, decl, ctx):
1146
+ continue
1147
+ elif ty == "property_declaration" and decl is not None:
1148
+ self._router_binding(c, ctx)
1149
+ self._refs(c, sf, owner, decl, ctx)
1150
+
1151
+ def _value_refs(self, c, sf: SFile, owner: str, decl: Decl | None):
1152
+ """USES_VALUE edges to enum cases and constants where the type is certain (#84): `Type.case`,
1153
+ `Type.constant`, a file-level constant by name, and `.case` where the contextual type is known: the
1154
+ subject of a `switch`, the other side of `==` / `!=`, a `let x: T = .case`, a parameter default."""
1155
+ ty = c.type
1156
+ line = c.start_point[0] + 1
1157
+ cls = self._encl_type(decl)
1158
+ self._at = (sf, line)
1159
+ if ty == "call_expression":
1160
+ f = c.children[0] if c.children else None
1161
+ if f is not None and f.type == "navigation_expression":
1162
+ self._value_refs(f, sf, owner, decl) # `E.c(1)`: the callee is not visited again
1163
+ return
1164
+ if ty == "navigation_expression":
1165
+ target = c.child_by_field_name("target")
1166
+ sfx = c.child_by_field_name("suffix")
1167
+ nmn = sfx.child_by_field_name("suffix") if sfx is not None else None
1168
+ if target is None or nmn is None or target.type not in ("simple_identifier", "navigation_expression"):
1169
+ return
1170
+ kind, tn = self._recv_type(self.t(target), decl, cls)
1171
+ if kind == "meta":
1172
+ self._value_edge(owner, tn, self.t(nmn), sf, line, "member")
1173
+ return
1174
+ if ty == "simple_identifier":
1175
+ nm = self.t(c)
1176
+ p = c.parent
1177
+ if nm in self.values.get("", {}) and p is not None and p.type not in NOT_A_READ \
1178
+ and p.type not in ("navigation_suffix", "pattern", "value_argument_label") \
1179
+ and not (p.type == "navigation_expression" and p.child_by_field_name("target") != c) \
1180
+ and not (decl is not None and (self._bound(c, nm) or self._shadowed(decl, nm, sf, line))) \
1181
+ and (decl is None or self._local(decl, nm) is None) and not self._is_callee(c) \
1182
+ and not (cls is not None and nm in self.stored_in.get(cls.fqn, ())):
1183
+ self._global_edge(owner, nm, sf, line)
1184
+ return
1185
+ if ty == "switch_statement":
1186
+ subj = next((x for x in c.children if x.is_named and x.type not in ("switch_entry", "comment")), None)
1187
+ tn = self._ctx_type(subj, decl, cls)
1188
+ if tn is None:
1189
+ return
1190
+ for e in c.children:
1191
+ if e.type != "switch_entry":
1192
+ continue
1193
+ for sp in e.children:
1194
+ if sp.type == "switch_pattern":
1195
+ self._implicit(sp, tn, owner, sf)
1196
+ return
1197
+ if ty == "equality_expression":
1198
+ kids = [x for x in c.children if x.is_named]
1199
+ if len(kids) == 2:
1200
+ for a, b in ((kids[0], kids[1]), (kids[1], kids[0])):
1201
+ if b.type in ("prefix_expression", "call_expression") and self.t(b).lstrip().startswith("."):
1202
+ tn = self._ctx_type(a, decl, cls)
1203
+ if tn is not None:
1204
+ self._implicit(b, tn, owner, sf)
1205
+ return
1206
+ if ty == "property_declaration":
1207
+ ta = next((x for x in c.children if x.type == "type_annotation"), None)
1208
+ val = next((x for x in c.children if x.type == "prefix_expression"), None)
1209
+ if ta is not None and val is not None:
1210
+ tn = self._type_name(ta.child_by_field_name("name"))
1211
+ t = self._type(tn)
1212
+ if t is not None:
1213
+ self._implicit(val, t.fqn, owner, sf)
1214
+ return
1215
+ if ty == "parameter":
1216
+ nx = c.next_sibling
1217
+ if nx is not None and nx.type == "=" and nx.next_sibling is not None \
1218
+ and nx.next_sibling.type == "prefix_expression":
1219
+ tn = self._type_name(next((y for y in c.children if y.is_named and y.type != "simple_identifier"),
1220
+ None))
1221
+ t = self._type(tn)
1222
+ if t is not None:
1223
+ self._implicit(nx.next_sibling, t.fqn, owner, sf)
1224
+
1225
+ def _ctx_type(self, n, decl: Decl | None, cls: Decl | None) -> str | None:
1226
+ """The project type of an expression used as a contextual type for `.case`: `self` inside the type, a
1227
+ parameter, a local or a property of known type."""
1228
+ if n is None or n.type not in ("simple_identifier", "navigation_expression", "self_expression"):
1229
+ return None
1230
+ kind, tn = self._recv_type(self.t(n), decl, cls)
1231
+ return tn if kind == "type" and tn in self.values else None
1232
+
1233
+ def _implicit(self, n, tn: str, owner: str, sf: SFile):
1234
+ """`.case` / `.case(let x)` / `.case?` written in `n` (a prefix expression or a switch pattern)."""
1235
+ txt = self.t(n).lstrip()
1236
+ m = re.match(r"^(?:case\s+)?\.\s*([A-Za-z_]\w*)", txt)
1237
+ if m:
1238
+ self._value_edge(owner, tn, m.group(1), sf, n.start_point[0] + 1, "contextual type")
1239
+
1240
+ def _global_edge(self, owner: str, nm: str, sf: SFile, line: int):
1241
+ """A file-level constant read by name: the one the reading file can see (its own `private let`, else a
1242
+ single visible one: same module or a dependency, a test file's only from tests); none when ambiguous."""
1243
+ cs = self.globals_.get(nm, ())
1244
+ own = [c for c in cs if c[1] == sf.rel]
1245
+ if not own:
1246
+ own = [c for c in cs if not c[3] and (sf.test or not c[2])
1247
+ and (not self.spm or self.spm.sees(sf.rel, c[1]))]
1248
+ if len(own) == 1 and own[0][0] != owner:
1249
+ self.b.add_edge(owner, own[0][0], "USES_VALUE", sf.rel, line, EXACT, how="file constant")
1250
+ self.st["value_refs"] += 1
1251
+
1252
+ def _preview_ref(self, c, sf: SFile, owner: str):
1253
+ """A SwiftUI preview run as a test (#100): `X_Previews._allPreviews` / `X_Previews.previews` (snapshot tests,
1254
+ Sourcery's generated preview tests) calls `X_Previews.previews`, and in a test file a string naming a preview
1255
+ provider (`performAccessibilityAudit(named: "X_Previews")`) does too, so `cg tests` reaches the views the
1256
+ previews build."""
1257
+ if c.type == "navigation_expression":
1258
+ target = c.child_by_field_name("target")
1259
+ sfx = c.child_by_field_name("suffix")
1260
+ nmn = sfx.child_by_field_name("suffix") if sfx is not None else None
1261
+ if target is None or nmn is None or target.type != "simple_identifier" \
1262
+ or self.t(nmn) not in ("_allPreviews", "previews"):
1263
+ return
1264
+ name, how = self.t(target), "preview"
1265
+ else:
1266
+ name, how = self.t(c).strip('"'), "preview name"
1267
+ if not name.endswith("_Previews") or not re.fullmatch(r"[A-Za-z_][\w.]*", name):
1268
+ return
1269
+ t = self._type(name)
1270
+ pid = f"method:{t.fqn}.previews" if t is not None else None
1271
+ if pid is None or pid not in self.decls or pid == owner:
1272
+ return
1273
+ self.b.add_edge(owner, pid, "CALLS", sf.rel, c.start_point[0] + 1, HEURISTIC, via=how)
1274
+ self.st["preview_refs"] += 1
1275
+
1276
+ def _value_edge(self, owner: str, tn: str, nm: str, sf: SFile, line: int, how: str):
1277
+ vid = self.values.get(tn, {}).get(nm)
1278
+ if vid is not None and vid != owner:
1279
+ self.b.add_edge(owner, vid, "USES_VALUE", sf.rel, line, EXACT, how=how)
1280
+ self.st["value_refs"] += 1
1281
+
1282
+ def _env_keypath(self, n) -> bool:
1283
+ """`\\.x` rooted in EnvironmentValues / FocusedValues: `@Environment(\\.x)`, `.environment(\\.x, v)`,
1284
+ `.transformEnvironment(\\.x)`, `@FocusedValue(\\.x)`; never a project type's stored property."""
1285
+ a, d = n.parent, 0
1286
+ while a is not None and d < 6:
1287
+ if a.type == "attribute":
1288
+ return True
1289
+ if a.type == "call_expression":
1290
+ return bool(re.search(r"(?i)(environment|focused\w*value|focusedSceneValue)\w*$", self.t(a.children[0])))
1291
+ a, d = a.parent, d + 1
1292
+ return False
1293
+
1294
+ @staticmethod
1295
+ def _is_subscript(p) -> bool:
1296
+ """`items[i]`: a call_expression whose suffix is `[...]`."""
1297
+ suf = next((x for x in p.children if x.type == "call_suffix"), None)
1298
+ va = next((x for x in suf.children if x.type == "value_arguments"), None) if suf is not None else None
1299
+ return va is not None and bool(va.children) and va.children[0].type == "["
1300
+
1301
+ @classmethod
1302
+ def _is_callee(cls, n) -> bool:
1303
+ p = n.parent
1304
+ return (p is not None and p.type == "call_expression" and bool(p.children) and p.children[0] == n
1305
+ and not cls._is_subscript(p))
1306
+
1307
+ def _is_write(self, n):
1308
+ """True for an assignment target; "mutating" for the receiver of a mutating standard-library method
1309
+ (`items.append(x)`, `flag.toggle()`) or of a `mutating func` declared in the project (`basket.add(x)`),
1310
+ "inout" for `&x`; False for a read."""
1311
+ p = n.parent
1312
+ if p is None:
1313
+ return False
1314
+ if p.type == "directly_assignable_expression":
1315
+ return True
1316
+ if p.type == "prefix_expression" and self.t(p).lstrip().startswith("&"):
1317
+ return "inout"
1318
+ if (p.type == "call_expression" and p.children and p.children[0] == n and self._is_subscript(p)
1319
+ and p.parent is not None and p.parent.type == "directly_assignable_expression"):
1320
+ return "item" # `items[i] = v` / `self.map[k] = v`
1321
+ if p.type == "navigation_expression" and p.child_by_field_name("target") == n and self._is_callee(p):
1322
+ sfx = p.child_by_field_name("suffix")
1323
+ m = sfx.child_by_field_name("suffix") if sfx is not None else None
1324
+ if m is not None and (self.t(m) in MUTATING_METHODS or self.t(m) in self.mutating_names):
1325
+ return "mutating"
1326
+ return False
1327
+
1328
+ def _prop_nav(self, c, sf: SFile, owner: str, decl: Decl | None):
1329
+ """`cart.label`, `self.summary`, `Cart.shared`: a read (or write) of a property node, resolved later."""
1330
+ sfx = c.child_by_field_name("suffix")
1331
+ nmn = sfx.child_by_field_name("suffix") if sfx is not None else None
1332
+ nm = self.t(nmn) if nmn is not None else None
1333
+ proj = bool(nm) and nm.startswith("$") and len(nm) > 1 # `vm.$query`: a Binding / Publisher
1334
+ if proj:
1335
+ nm = nm[1:]
1336
+ if not nm or (nm not in self.prop_names and nm not in self.field_names) or self._is_callee(c):
1337
+ return
1338
+ target = c.child_by_field_name("target")
1339
+ if target is None:
1340
+ return
1341
+ if target.type == "key_path_expression":
1342
+ root = self.t(target).lstrip("\\").strip() # `\Basket.items`; `\.items` has no named root
1343
+ if nm in self.field_names and (re.fullmatch(r"[A-Z]\w*(\.\w+)*", root)
1344
+ or (not root and not self._env_keypath(c))):
1345
+ self.prop_refs.append((owner, nm, "\\" + root, c.start_point[0] + 1, sf, decl, "keypath"))
1346
+ return
1347
+ recv = re.sub(r"^\s*[!\-~&]+", "", self.t(target))
1348
+ self.prop_refs.append((owner, nm, recv, c.start_point[0] + 1, sf, decl, "binding" if proj else self._is_write(c)))
1349
+
1350
+ def _prop_bare(self, c, sf: SFile, owner: str, decl: Decl | None):
1351
+ """`summary` inside a member of the type that declares it (an implicit `self.summary`)."""
1352
+ nm = self.t(c)
1353
+ proj = nm.startswith("$") and len(nm) > 1 and not nm[1:].isdigit() # `$query`: the wrapper's projection
1354
+ if proj:
1355
+ nm = nm[1:]
1356
+ p = c.parent
1357
+ if (nm not in self.prop_names and nm not in self.field_names) or p is None or p.type in NOT_A_READ or self._is_callee(c):
1358
+ return
1359
+ if p.type == "value_argument" and p.child_by_field_name("value") != c:
1360
+ return
1361
+ if p.type == "prefix_expression" and self.t(p).lstrip().startswith("."):
1362
+ return
1363
+ if p.type == "navigation_expression" and p.child_by_field_name("target") != c:
1364
+ return
1365
+ if c.prev_sibling is not None and c.prev_sibling.type == ".":
1366
+ return # `guard case .success = self`: an implicit member
1367
+ line = c.start_point[0] + 1
1368
+ if decl is None or decl.kind == "class" or self._bound(c, nm) or self._shadowed(decl, nm, sf, line):
1369
+ return
1370
+ self.prop_refs.append((owner, nm, None, line, sf, decl, "binding" if proj else self._is_write(c)))
1371
+
1372
+ def _bound(self, c, nm: str) -> bool:
1373
+ """A parameter of an enclosing function, initializer, subscript or closure is named `nm`."""
1374
+ a = c.parent
1375
+ while a is not None and a.type not in TYPE_DECLS:
1376
+ if a.type in ("function_declaration", "init_declaration", "subscript_declaration", "lambda_literal"):
1377
+ for x in a.children:
1378
+ if x.type == "parameter" or x.type == "lambda_function_type":
1379
+ for y in ([x] if x.type == "parameter" else
1380
+ [z for q in x.children if q.type == "lambda_function_type_parameters"
1381
+ for z in q.children if z.type == "lambda_parameter"]):
1382
+ n = y.child_by_field_name("name")
1383
+ if n is not None and self.t(n) == nm:
1384
+ return True
1385
+ a = a.parent
1386
+ return False
1387
+
1388
+ def _shadowed(self, decl: Decl, nm: str, sf: SFile, line: int) -> bool:
1389
+ """A local, closure parameter, `case let` binding or loop variable of that name in the member above `line`."""
1390
+ if nm in decl.types:
1391
+ return True
1392
+ txt = "\n".join(sf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:line])
1393
+ n = re.escape(nm)
1394
+ return bool(re.search(rf"\b(?:let|var)\s+[^=\n{{}}]*?\b{n}\b|\b{n}\s*(?:,\s*\w+\s*)*\)?\s+in\b|"
1395
+ rf"\bfor\s+(?:case\s+)?(?:let\s+)?\(?[\w\s,]*\b{n}\b", txt))
1396
+
1397
+ def _callee(self, c):
1398
+ """(receiver text, name, value_arguments node, trailing lambda, line) of a call_expression. The line is the
1399
+ call's own line: that of the receiver's last operand when the expression starts on an earlier line
1400
+ (`let ok = a` / ` + Scorer.score(x)` parses as `(a + Scorer).score(x)`)."""
1401
+ line = c.start_point[0] + 1
1402
+ if not c.children:
1403
+ return None, None, None, None, line
1404
+ f = c.children[0]
1405
+ while f.child_by_field_name("rhs") is not None:
1406
+ # `a + weight(forExtraIndex: 2)` parses as `(a + weight)(forExtraIndex: 2)`: the callee is the right operand
1407
+ f = f.child_by_field_name("rhs")
1408
+ line = f.start_point[0] + 1
1409
+ suf = next((x for x in c.children if x.type == "call_suffix"), None)
1410
+ args = next((x for x in suf.children if x.type == "value_arguments"), None) if suf is not None else None
1411
+ lam = next((x for x in suf.children if x.type in ("lambda_literal", "annotated_lambda")), None) if suf is not None else None
1412
+ if f.type == "simple_identifier":
1413
+ return None, self.t(f), args, lam, line
1414
+ if f.type == "navigation_expression":
1415
+ target = f.child_by_field_name("target") or f.children[0]
1416
+ sfx = next((x for x in f.children if x.type == "navigation_suffix"), None)
1417
+ nm = self.t(sfx).lstrip(".").strip() if sfx is not None else None
1418
+ if target is None or target.type == "navigation_suffix":
1419
+ return "", nm, args, lam, line
1420
+ err = next((x for x in f.children if x.type == "ERROR"), None)
1421
+ if sfx is not None and (err is not None or target.type in ("<", ">", "<=", ">=")):
1422
+ # `let ok = a` / ` < Scorer.score(x)`: a continuation line starting with `<` / `>` does not parse;
1423
+ # the receiver is the text between the operator and the member name
1424
+ recv = self.cur.src[f.start_byte:sfx.start_byte].decode("utf-8", "replace")
1425
+ if err is not None:
1426
+ line = err.start_point[0] + 1
1427
+ else:
1428
+ recv = self.t(target)
1429
+ rhs = target
1430
+ while rhs.child_by_field_name("rhs") is not None: # binary expression: its right operand
1431
+ rhs = rhs.child_by_field_name("rhs")
1432
+ if rhs is not target:
1433
+ line = rhs.start_point[0] + 1
1434
+ m = re.match(r"\s*([!~+\-<>=&|*/%^?]+)\s*", recv)
1435
+ if m and not recv.lstrip().startswith(".."):
1436
+ # a prefix operator (`!Preview.matches(a, b)`, `-Offset.value()`) applies to the call's result, not
1437
+ # to the receiver; a leading binary operator is the end of a broken continuation line
1438
+ recv = recv[m.end():]
1439
+ return recv, nm, args, lam, line
1440
+ return None, None, args, lam, line
1441
+
1442
+ def _args(self, args) -> list[tuple[str | None, object]]:
1443
+ out = []
1444
+ for a in (args.children if args is not None else []):
1445
+ if a.type == "value_argument":
1446
+ lab = next((x for x in a.children if x.type == "value_argument_label"), None)
1447
+ val = a.child_by_field_name("value") or next((x for x in reversed(a.children) if x.is_named), None)
1448
+ out.append((self.t(lab).strip() if lab is not None else None, val))
1449
+ return out
1450
+
1451
+ def _call(self, c, sf: SFile, owner: str, decl: Decl | None, ctx: dict) -> bool:
1452
+ recv, name, args, lam, line = self._callee(c)
1453
+ if not name or not re.match(r"^\w+$", name):
1454
+ return False
1455
+ al = self._args(args)
1456
+ # ---- SwiftUI / UIKit navigation
1457
+ if name == "NavigationLink":
1458
+ for lab, v in al:
1459
+ if lab == "destination" and v is not None:
1460
+ self._nav_target(v, owner, sf, line, "NavigationLink")
1461
+ if name == "NavigationLink" or (name in ("navigate", "push", "append") and recv is not None
1462
+ and NAV_RECV.search(recv)):
1463
+ for lab, v in al[:1] if name != "NavigationLink" else al:
1464
+ if v is not None and (name != "NavigationLink" or lab == "value") and \
1465
+ (name != "navigate" or lab == "to"):
1466
+ m = re.match(r"\s*([A-Z]\w*)?\s*\.\s*([a-z]\w*)\b", self.t(v))
1467
+ if m and m.group(2) not in ("init", "self"):
1468
+ self.value_navs.append((owner, m.group(1), m.group(2),
1469
+ "NavigationLink(value:)" if name == "NavigationLink" else f"{name}(...)",
1470
+ sf.rel, line))
1471
+ if name == "navigationDestination" and lam is not None:
1472
+ ty = next((re.match(r"\s*([A-Z][\w.]*)\s*\.\s*self\s*$", self.t(v)) for lab, v in al
1473
+ if lab == "for" and v is not None), None)
1474
+ if ty:
1475
+ self._destinations(lam, ty.group(1).split(".")[-1], owner, sf, line)
1476
+ if name in PRESENT and lam is not None:
1477
+ self._lambda_views(lam, owner, sf, line, f".{name}")
1478
+ if name in ("pushViewController", "present", "show") and al and al[0][1] is not None:
1479
+ self._nav_target(al[0][1], owner, sf, line, name)
1480
+ if name in ("WindowGroup", "TabView") and lam is not None and recv is None:
1481
+ self._lambda_views(lam, owner, sf, line, name, page_only=name == "WindowGroup")
1482
+ # ---- Alamofire
1483
+ if name == "request" and recv is not None and re.match(r"^(AF|Alamofire|session|\w*[Ss]ession)$", recv) and al:
1484
+ url = self._url_text(al[0][1])
1485
+ meth = next((self.t(v).lstrip(".").upper() for lab, v in al if lab == "method" and v is not None), "GET")
1486
+ if url is not None:
1487
+ self.http.append({"src": owner, "method": meth, "url": url, "client": "alamofire", "file": sf.rel,
1488
+ "line": line})
1489
+ # ---- Moya: provider.request(.case) -> the TargetType case's endpoint
1490
+ self._moya_call(name, recv, al, owner, sf, line)
1491
+ # ---- Fluent: Todo.query(on:) / .find / todo.save(on:)
1492
+ self._fluent_call(name, recv, owner, decl, sf, line, c)
1493
+ # ---- BGTaskScheduler
1494
+ if name == "register" and "BGTaskScheduler" in (recv or "") and lam is not None:
1495
+ hid = self.b.add_node("function", f"<bgtask>@{sf.rel}:{line}", name="background task handler", file=sf.rel,
1496
+ line=line, lang="swift", entry_kind="queue_job", attrs={"lambda": True})
1497
+ self.st["background_tasks"] += 1
1498
+ self._refs(lam, sf, hid, decl, ctx)
1499
+ return True
1500
+ # ---- Vapor routing
1501
+ routers = ctx.get("routers")
1502
+ if routers is not None and recv is not None:
1503
+ base = self._router(recv, routers)
1504
+ if base is not None:
1505
+ if name in VERBS or name == "on":
1506
+ self._vapor_route(name, al, lam, base, sf, owner, decl, c, ctx)
1507
+ return lam is not None
1508
+ if name in ("group", "grouped") and lam is not None:
1509
+ pfx, guards = self._group_args(al, base)
1510
+ pname = self._lambda_param(lam)
1511
+ inner = dict(routers)
1512
+ if pname:
1513
+ inner[pname] = (pfx, guards)
1514
+ self._refs(lam, sf, owner, decl, {**ctx, "routers": inner})
1515
+ return True
1516
+ if name[:1].isupper() and recv is None:
1517
+ br = self._branch(c)
1518
+ if br:
1519
+ self.branch_of[(owner, line, name)] = br
1520
+ self.branch_at[(sf.rel, line, name)] = br
1521
+ self.calls.append((owner, name, recv, line, sf, decl, tuple(lab or "_" for lab, _v in al), lam is not None))
1522
+ return False
1523
+
1524
+ def _branch(self, c) -> dict:
1525
+ """The innermost `switch` case / `if` / `guard` / ternary branch around a construction (#88): attrs.branch
1526
+ (`case .settings`, `if isEditing`, `else of if isEditing`) and attrs.branch_line, so the parent that rebuilds
1527
+ or swaps a view under a condition is visible on its INSTANTIATES edge."""
1528
+ x, prev = c.parent, c
1529
+ while x is not None and x.type not in BRANCH_STOP:
1530
+ t = x.type
1531
+ if t == "switch_entry":
1532
+ pats = [self.t(p) for p in x.children if p.type == "switch_pattern"]
1533
+ lab = ("case " + ", ".join(pats)) if pats else "default"
1534
+ return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
1535
+ if t in ("if_statement", "guard_statement"):
1536
+ ch = x.children
1537
+ els = next((k for k in ch if k.type == "else"), None)
1538
+ cond = []
1539
+ for k in ch[1:]:
1540
+ if k.type in ("{", "else", "statements"):
1541
+ break
1542
+ cond.append(self.t(k))
1543
+ kw = "guard" if t == "guard_statement" else "if"
1544
+ lab = f"{kw} {' '.join(cond)}"
1545
+ if els is not None and prev.start_byte > els.start_byte:
1546
+ lab = "else of " + lab
1547
+ return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
1548
+ if t == "ternary_expression" and x.children:
1549
+ colon = next((k for k in x.children if k.type == ":"), None)
1550
+ lab = f"{self.t(x.children[0])} ?"
1551
+ if colon is not None and prev.start_byte > colon.start_byte:
1552
+ lab = "else of " + lab
1553
+ if prev is not x.children[0]:
1554
+ return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
1555
+ prev, x = x, x.parent
1556
+ return {}
1557
+
1558
+ # ---- Vapor helpers
1559
+ def _router(self, recv: str, routers: dict):
1560
+ """(prefix, guards) of a router expression: app / routes / req.application, a variable bound to a group, or
1561
+ a chained `x.grouped(...)`."""
1562
+ recv = recv.strip()
1563
+ if recv in routers:
1564
+ return routers[recv]
1565
+ if re.match(r"^(app|application|routes|router|req\.application|self\.app)$", recv):
1566
+ return ("", [])
1567
+ m = re.match(r"^(\w+)\s*\.\s*grouped\s*\((.*)\)\s*$", recv, re.S)
1568
+ if m:
1569
+ base = self._router(m.group(1), routers)
1570
+ if base is not None:
1571
+ return self._group_text(m.group(2), base)
1572
+ return None
1573
+
1574
+ def _group_text(self, inner: str, base):
1575
+ pfx, guards = base
1576
+ segs = re.findall(r'"([^"]*)"', inner)
1577
+ mws = [re.sub(r"\(\s*\)$", "", x.strip()) for x in re.split(r",(?![^()]*\))", inner) if x.strip() and '"' not in x]
1578
+ return join_path(pfx, "/".join(vapor_seg(s) for s in segs)), guards + [m for m in mws if m]
1579
+
1580
+ def _group_args(self, al, base):
1581
+ return self._group_text(", ".join(self.t(v) for _, v in al if v is not None), base)
1582
+
1583
+ def _router_binding(self, c, ctx: dict):
1584
+ """let protected = app.grouped("v1").grouped(User.authenticator()) -> routers["protected"]."""
1585
+ routers = ctx.get("routers")
1586
+ if routers is None:
1587
+ return
1588
+ nm = self._name(c)
1589
+ txt = self.t(c)
1590
+ m = re.match(r"^\s*(?:let|var)\s+\w+\s*(?::[^=]+)?=\s*(.+)$", txt, re.S)
1591
+ if not nm or not m:
1592
+ return
1593
+ expr = m.group(1).strip()
1594
+ parts = re.match(r"^([\w.]+)((?:\s*\.\s*grouped\s*\((?:[^()]|\([^()]*\))*\))+)\s*$", expr, re.S)
1595
+ if not parts:
1596
+ return
1597
+ base = self._router(parts.group(1), routers)
1598
+ if base is None:
1599
+ return
1600
+ for g in re.findall(r"grouped\s*\(((?:[^()]|\([^()]*\))*)\)", parts.group(2)):
1601
+ base = self._group_text(g, base)
1602
+ routers[nm] = base
1603
+
1604
+ def _lambda_param(self, lam) -> str | None:
1605
+ m = re.match(r"\{\s*\(?\s*(\w+)", self.t(lam))
1606
+ return m.group(1) if m and re.search(r"\bin\b", self.t(lam).split("\n")[0]) else None
1607
+
1608
+ def _vapor_route(self, verb, al, lam, base, sf, owner, decl, c, ctx):
1609
+ pfx, guards = base
1610
+ segs, handler, method = [], None, verb.upper()
1611
+ for lab, v in al:
1612
+ if v is None:
1613
+ continue
1614
+ if lab is None and v.type == "line_string_literal":
1615
+ segs.append(vapor_seg(template(self.t(v))))
1616
+ elif lab == "use":
1617
+ handler = self.t(v)
1618
+ elif lab is None and verb == "on":
1619
+ method = self.t(v).lstrip(".").upper()
1620
+ uri = "/" + join_path(pfx, "/".join(segs)).strip("/")
1621
+ line = c.start_point[0] + 1
1622
+ key = f"{method} {uri}"
1623
+ attrs = {"uri": uri, "method": method, "framework": "vapor"}
1624
+ if guards:
1625
+ attrs["middleware"] = list(dict.fromkeys(guards))
1626
+ rid = self.b.add_node("route", key, name=key, file=sf.rel, line=line, lang="swift", entry_kind="http_route",
1627
+ attrs=attrs)
1628
+ self.b.nodes[rid].entry_kind = self.b.nodes[rid].entry_kind or "http_route"
1629
+ self.st["routes"] += 1
1630
+ self.st["routes_vapor"] += 1
1631
+ self.b.add_edge(owner, rid, "REFERENCES_FN", sf.rel, line, EXACT, how="router registration")
1632
+ if lam is not None:
1633
+ hk = f"<{key}>@{sf.rel}:{line}"
1634
+ hid = self.b.add_node("function", hk, name=f"{key} handler", fqn=hk, file=sf.rel, line=line,
1635
+ end_line=c.end_point[0] + 1, lang="swift",
1636
+ attrs={"lambda": True, "swift_kind": "route handler", **({"test": True} if sf.test else {})})
1637
+ self.b.add_edge(rid, hid, "ROUTES_TO", sf.rel, line, EXACT)
1638
+ self._refs(lam, sf, hid, decl, {**ctx, "routers": None})
1639
+ elif handler:
1640
+ h = re.sub(r"^self\.", "", handler).split("(")[0]
1641
+ tfq = decl.cls if decl is not None and decl.cls else (decl.fqn if decl is not None and decl.kind == "class" else None)
1642
+ if tfq:
1643
+ self.handler_refs.append((rid, h, tfq, sf.rel, line))
1644
+
1645
+ # ---- navigation helpers
1646
+ def _nav_target(self, v, owner, sf, line, how):
1647
+ m = re.match(r"\s*(?:\w+\s*\.\s*)?([A-Z]\w*)\s*\(", self.t(v))
1648
+ if m:
1649
+ self.navs.append((owner, m.group(1), how, sf.rel, line))
1650
+
1651
+ def _view_names(self, stm) -> list[str]:
1652
+ out = []
1653
+ for x in (stm.children if stm is not None else []):
1654
+ node = x
1655
+ while node.type in ("call_expression", "navigation_expression") and node.children and \
1656
+ node.children[0].type in ("call_expression", "navigation_expression"):
1657
+ node = node.children[0]
1658
+ m = re.match(r"\s*([A-Z]\w*)\s*\(", self.t(node))
1659
+ if m and m.group(1) not in CONTAINER_VIEWS:
1660
+ out.append(m.group(1))
1661
+ return out
1662
+
1663
+ def _destinations(self, lam, tname: str, owner, sf, line):
1664
+ """`navigationDestination(for: Route.self) { r in switch r { case .detail(let id): DetailView(id: id) } }`:
1665
+ the views per case of Route, for `NavigationLink(value: Route.detail(...))` / `router.navigate(to: .detail)`
1666
+ (#68). The modifier's holder navigates to every one of them."""
1667
+ stm = next((x for x in lam.children if x.type == "statements"), None)
1668
+ for sw in (stm.children if stm is not None else []):
1669
+ if sw.type != "switch_statement":
1670
+ continue
1671
+ for e in sw.children:
1672
+ if e.type != "switch_entry":
1673
+ continue
1674
+ cases = []
1675
+ for sp in e.children:
1676
+ if sp.type == "switch_pattern":
1677
+ m = re.match(r"\s*(?:[A-Z]\w*)?\s*\.\s*(\w+)", self.t(sp))
1678
+ if m:
1679
+ cases.append(m.group(1))
1680
+ if not cases and not any(x.type == "default_keyword" or self.t(x) == "default" for x in e.children):
1681
+ continue
1682
+ views = self._view_names(next((x for x in e.children if x.type == "statements"), None))
1683
+ for v in views:
1684
+ for cs in cases or [None]:
1685
+ self.dest_map[tname][cs].append((v, sf.rel, line))
1686
+ self.navs.append((owner, v, ".navigationDestination(for:)", sf.rel, line))
1687
+ self.st["navigation_destinations"] += 1
1688
+ for v in self._view_names(stm): # no switch: one view for every value
1689
+ self.dest_map[tname][None].append((v, sf.rel, line))
1690
+
1691
+ def _lambda_views(self, lam, owner, sf, line, how, page_only=False):
1692
+ stm = next((x for x in lam.children if x.type == "statements"), None)
1693
+ for x in (stm.children if stm is not None else []):
1694
+ node = x
1695
+ while node.type in ("call_expression", "navigation_expression") and node.children and \
1696
+ node.children[0].type in ("call_expression", "navigation_expression"):
1697
+ node = node.children[0] # View(...).tabItem { } -> View(...)
1698
+ m = re.match(r"\s*([A-Z]\w*)\s*\(", self.t(node))
1699
+ if m and m.group(1) not in CONTAINER_VIEWS:
1700
+ self.navs.append((owner, m.group(1), how, sf.rel, line, page_only))
1701
+
1702
+ # ---- URLSession
1703
+ def _url_text(self, v) -> str | None:
1704
+ if v is None:
1705
+ return None
1706
+ txt = self.t(v)
1707
+ if v.type == "line_string_literal":
1708
+ return template(txt)
1709
+ m = re.search(r'URL\s*\(\s*string\s*:\s*("(?:[^"\\]|\\.)*")', txt)
1710
+ if m:
1711
+ return template(m.group(1))
1712
+ return None
1713
+
1714
+ COMP_INIT = re.compile(r"(\w+)\s*=\s*URLComponents\s*\(\s*(?:string\s*:\s*(\"(?:[^\"\\]|\\.)*\"))?")
1715
+
1716
+ def _components_url(self, txt: str) -> str | None:
1717
+ """`var c = URLComponents(string: "https://api.x.com")` / `URLComponents()` with `c.scheme` / `c.host` /
1718
+ `c.path = "/v1/users/\\(id)"` set after it (#68); query items are not part of the route."""
1719
+ m = self.COMP_INIT.search(txt)
1720
+ if not m:
1721
+ return None
1722
+ v = re.escape(m.group(1))
1723
+
1724
+ def field(f):
1725
+ fm = re.search(rf"\b{v}\s*\.\s*{f}\s*=\s*(\"(?:[^\"\\]|\\.)*\")", txt)
1726
+ return template(fm.group(1)) if fm else None
1727
+ base = template(m.group(2)) if m.group(2) else None
1728
+ path = field("path")
1729
+ if base is None:
1730
+ host = field("host")
1731
+ if host is None and path is None:
1732
+ return None
1733
+ base = (f"{field('scheme') or 'https'}://{host}" if host else "")
1734
+ if path is None:
1735
+ return base or None
1736
+ return base.rstrip("/") + "/" + path.lstrip("/")
1737
+
1738
+ def _fn_http(self, fn, sf: SFile, d: Decl):
1739
+ txt = self.t(fn)
1740
+ if not URLSESSION.search(txt) or "URLSession" not in txt and "session" not in txt:
1741
+ return
1742
+ url = None
1743
+ for m in re.finditer(r'URL\s*\(\s*string\s*:\s*("(?:[^"\\]|\\.)*")', txt):
1744
+ url = template(m.group(1))
1745
+ if url is None:
1746
+ url = self._components_url(txt)
1747
+ if url is None:
1748
+ base = re.search(r"(\w+)\s*\.\s*appending(?:PathComponent|Path)?\s*\(\s*(?:path\s*:\s*)?(\"(?:[^\"\\]|\\.)*\")", txt)
1749
+ if base:
1750
+ url = "{" + base.group(1) + "}/" + template(base.group(2)).lstrip("/")
1751
+ if url is None:
1752
+ self.st["urlsession_url_unknown"] += 1
1753
+ return
1754
+ mm = re.search(r'httpMethod\s*=\s*"(\w+)"', txt)
1755
+ self.http.append({"src": d.id, "method": (mm.group(1) if mm else "GET").upper(), "url": url, "client": "urlsession",
1756
+ "file": sf.rel, "line": d.line})
1757
+
1758
+ # ---- @available(macOS, unavailable): the declaration (and its members) does not exist on that platform;
1759
+ # @available(*, unavailable): on none. Version forms (`@available(iOS 17, *)`, `@available(iOS, introduced: 15)`,
1760
+ # `if #available(iOS 17, *)`, `guard #available`, `if #unavailable`) keep the code on every target and record the
1761
+ # minimum OS versions: node attrs.available on the declaration (members inherit the type's), edge attrs.available
1762
+ # on the references in the guarded branch. `deprecated` is kept as attrs.deprecated.
1763
+ AVAILABLE = re.compile(r"@available\s*\(\s*(iOS|macOS|OSX)\s*,\s*unavailable\b")
1764
+ AVAIL_ATTR = re.compile(r"@available\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)")
1765
+ AVAIL_VER = re.compile(r"\b(iOS|iPadOS|macOS|OSX|watchOS|tvOS|visionOS|macCatalyst)(?:ApplicationExtension)?\s*"
1766
+ r"(?:,\s*introduced\s*:\s*)?(\d+(?:\.\d+)*)")
1767
+
1768
+ @staticmethod
1769
+ def _vmax(a: dict, b: dict) -> dict:
1770
+ out = dict(a)
1771
+ for k, v in b.items():
1772
+ if k not in out or tuple(int(x) for x in v.split(".")) > tuple(int(x) for x in out[k].split(".")):
1773
+ out[k] = v
1774
+ return out
1775
+
1776
+ def _available(self, sf: SFile):
1777
+ if b"available" not in sf.src:
1778
+ return
1779
+ from ...platforms import KNOWN, Cond, _plat_atom, mark
1780
+ lines = sf.src.decode("utf-8", "replace").split("\n")
1781
+ decl_av = []
1782
+ for d in sorted(self._fd.get(sf.rel, ()), key=lambda d: d.line - d.end): # outer declarations first
1783
+ head = "\n".join(lines[d.line - 1:d.end]).split("{", 1)[0]
1784
+ for m in self.AVAILABLE.finditer(head):
1785
+ plat = OS_PLATFORM[m.group(1)]
1786
+ if plat == "macos" and self.mac == "catalyst":
1787
+ continue # `@available(macOS, unavailable)` does not apply to a Catalyst build
1788
+ atom = self._apple(plat) if plat == "ios" else _plat_atom(plat) # iOS-unavailable: Catalyst too
1789
+ mark(self.b, sf.rel, d.line, d.end, Cond("tree", ("not", atom), m.group(0) + ")"))
1790
+ self.st["platform_unavailable"] += 1
1791
+ av, dep = {}, None
1792
+ for m in self.AVAIL_ATTR.finditer(head):
1793
+ args = m.group(1)
1794
+ if re.match(r"\s*\*\s*,\s*unavailable\b", args):
1795
+ mark(self.b, sf.rel, d.line, d.end, Cond("tree", ("all", [("not", _plat_atom(p)) for p in KNOWN]),
1796
+ "@available(*, unavailable)"))
1797
+ self.st["platform_unavailable_everywhere"] += 1
1798
+ continue
1799
+ if re.search(r"\bdeprecated\b", args):
1800
+ mm = re.search(r'message\s*:\s*"((?:[^"\\]|\\.)*)"', args)
1801
+ dep = mm.group(1)[:160] if mm else True
1802
+ if "unavailable" not in args and "obsoleted" not in args:
1803
+ for v in self.AVAIL_VER.finditer(args):
1804
+ av = self._vmax(av, {"macOS" if v.group(1) == "OSX" else v.group(1): v.group(2)})
1805
+ for a, z, pav in decl_av: # members inherit the enclosing type's availability
1806
+ if a <= d.line and d.end <= z and (a, z) != (d.line, d.end):
1807
+ av = self._vmax(pav, av)
1808
+ if av:
1809
+ decl_av.append((d.line, d.end, av))
1810
+ declared, av = av, self._unmet(av, sf.rel, d.line, "@available")
1811
+ n = self.b.nodes.get(d.id)
1812
+ if n is not None and (av or dep or declared):
1813
+ n.attrs = dict(n.attrs or {})
1814
+ if av:
1815
+ n.attrs["available"] = av
1816
+ self.st["available_declarations"] += 1
1817
+ if declared and declared != av:
1818
+ n.attrs["available_declared"] = declared
1819
+ if dep:
1820
+ n.attrs["deprecated"] = dep
1821
+ if b"#available" not in sf.src and b"#unavailable" not in sf.src:
1822
+ return
1823
+ regions = self._avail_regions.setdefault(sf.rel, [])
1824
+
1825
+ def walk(n):
1826
+ for c in n.children:
1827
+ if c.type == "availability_condition":
1828
+ self._avail_branch(c, regions)
1829
+ walk(c)
1830
+ walk(sf.tree.root_node)
1831
+
1832
+ def _avail_branch(self, c, regions: list):
1833
+ txt = self.t(c)
1834
+ av = {}
1835
+ for v in self.AVAIL_VER.finditer(txt):
1836
+ av = self._vmax(av, {"macOS" if v.group(1) == "OSX" else v.group(1): v.group(2)})
1837
+ if not av:
1838
+ return
1839
+ neg = "#unavailable" in txt.replace(" ", "")
1840
+ p = c.parent
1841
+ if p is None:
1842
+ return
1843
+ kids = p.children
1844
+ i = next((k for k, x in enumerate(kids) if x.id == c.id), None)
1845
+ if i is None:
1846
+ return
1847
+ if p.type == "if_statement":
1848
+ opens = [k for k in range(i + 1, len(kids)) if kids[k].type == "{"]
1849
+ closes = [k for k in range(i + 1, len(kids)) if kids[k].type == "}"]
1850
+ if not opens or not closes:
1851
+ return
1852
+ if not neg:
1853
+ a, z = kids[opens[0]].start_point[0] + 1, kids[closes[0]].start_point[0] + 1
1854
+ else: # `if #unavailable(iOS 17) { old } else { new }`: the else branch
1855
+ if len(opens) < 2:
1856
+ return
1857
+ a, z = kids[opens[1]].start_point[0] + 1, p.end_point[0] + 1
1858
+ elif p.type == "guard_statement" and not neg:
1859
+ stmts = p.parent
1860
+ a, z = p.end_point[0] + 2, (stmts.end_point[0] + 1 if stmts is not None else p.end_point[0] + 1)
1861
+ else:
1862
+ return
1863
+ av = self._unmet(av, self.cur.rel if self.cur is not None else "", c.start_point[0] + 1,
1864
+ "#unavailable" if neg else "#available")
1865
+ if z >= a and av:
1866
+ regions.append((a, z, av))
1867
+ self.st["available_branches"] += 1
1868
+
1869
+ def _unmet(self, av: dict, rel: str, line: int, what: str) -> dict:
1870
+ """The part of an availability requirement the deployment target does not already meet (#100): with iOS
1871
+ 18.5 deployed, `@available(iOS 17, *)` / `if #available(iOS 17, *)` always hold, so the declaration or branch
1872
+ gets no attrs.available and the check is listed as `availability_always_true` in the index stats."""
1873
+ if not av or not self.deploy:
1874
+ return av
1875
+ def ver(v):
1876
+ return tuple(int(x) for x in v.split("."))
1877
+ out = {k: v for k, v in av.items() if k not in self.deploy or ver(v) > ver(self.deploy[k])}
1878
+ if len(out) < len(av):
1879
+ self.st["availability_checks_met_by_deployment"] += 1
1880
+ if not out:
1881
+ lst = self.st.setdefault("availability_always_true", [])
1882
+ if len(lst) < 50:
1883
+ lst.append({"file": rel, "line": line, "check": what,
1884
+ "requires": ", ".join(f"{k} {v}" for k, v in av.items()),
1885
+ "deployment": ", ".join(f"{k} {self.deploy[k]}" for k in av if k in self.deploy)})
1886
+ return out
1887
+
1888
+ def _apply_available(self):
1889
+ """References inside `if #available(...)` / after `guard #available(...)`: edge attrs.available."""
1890
+ if not any(self._avail_regions.values()):
1891
+ return
1892
+ for e in self.b.edges.values():
1893
+ regs = self._avail_regions.get(e.file)
1894
+ if not regs or e.line is None:
1895
+ continue
1896
+ av = {}
1897
+ for a, z, v in regs:
1898
+ if a <= e.line <= z:
1899
+ av = self._vmax(av, v)
1900
+ if av:
1901
+ e.attrs = {**(e.attrs or {}), "available": av}
1902
+ self.st["available_references"] += 1
1903
+
1904
+ # ---- #if os(...)
1905
+ def _directives(self, sf: SFile):
1906
+ from ...platforms import Cond, _plat_atom, mark
1907
+ dirs = []
1908
+ def walk(n):
1909
+ for c in n.children:
1910
+ if c.type == "directive":
1911
+ dirs.append(c)
1912
+ else:
1913
+ walk(c)
1914
+ walk(sf.tree.root_node)
1915
+ stack = [] # [(start line, cond text, previous conds)]
1916
+ for d in dirs:
1917
+ txt = self.t(d).strip()
1918
+ line = d.start_point[0] + 1
1919
+ kw = re.match(r"#(if|elseif|else|endif)\b\s*(.*)", txt)
1920
+ if not kw:
1921
+ continue
1922
+ k, expr = kw.group(1), kw.group(2)
1923
+ if k in ("elseif", "else", "endif") and stack:
1924
+ start, cur, prev, ctext = stack.pop()
1925
+ if cur is not None:
1926
+ mark(self.b, sf.rel, start, line - 1, Cond("tree", cur, ctext))
1927
+ self.st["platform_blocks"] += 1
1928
+ prev = prev + ([cur] if cur is not None else [])
1929
+ if k == "endif":
1930
+ continue
1931
+ new = self._os_expr(expr) if k == "elseif" else None
1932
+ if k == "else":
1933
+ new = ("all", [("not", p) for p in prev]) if prev else None
1934
+ elif new is not None and prev:
1935
+ new = ("all", [new] + [("not", p) for p in prev])
1936
+ stack.append((line + 1, new, prev, txt))
1937
+ elif k == "if":
1938
+ stack.append((line + 1, self._os_expr(expr), [], txt))
1939
+ for first, last, iftxt in sf.attr_blocks:
1940
+ # `#if os(macOS)` / `@Test` / `#endif` / `func f()`: f is declared under that condition
1941
+ kw = re.match(r"#if\b\s*(.*)", iftxt or "")
1942
+ cond = self._os_expr(kw.group(1)) if kw else None
1943
+ if cond is None:
1944
+ continue
1945
+ for d in self._fd.get(sf.rel, ()):
1946
+ if first < d.line < last:
1947
+ mark(self.b, sf.rel, d.line, d.end, Cond("tree", cond, iftxt))
1948
+ self.st["platform_blocks"] += 1
1949
+
1950
+ def _os_expr(self, expr: str):
1951
+ """Platform condition tree of a `#if` expression: `os()`, `canImport()`, `targetEnvironment()` joined by
1952
+ `||`, `&&`, `!` and parentheses (`(os(iOS) && canImport(CoreTelephony)) || os(tvOS)`, #86). An `||` with a
1953
+ term that names no platform is unknown; such a term in an `&&` is left out (`os(iOS) && DEBUG`: iOS), except
1954
+ under a `!` where leaving it out would claim too much."""
1955
+ expr = re.sub(r"//.*|/\*.*?\*/", "", expr).strip()
1956
+ toks = re.findall(r"\|\||&&|!|\(|\)|[^\s()!&|]+(?:\s*\([^()]*\))?", expr)
1957
+ pos = [0]
1958
+
1959
+ def peek():
1960
+ return toks[pos[0]] if pos[0] < len(toks) else None
1961
+
1962
+ def take():
1963
+ pos[0] += 1
1964
+ return toks[pos[0] - 1]
1965
+
1966
+ def p_or(strict):
1967
+ parts = [p_and(strict)]
1968
+ while peek() == "||":
1969
+ take()
1970
+ parts.append(p_and(strict))
1971
+ if len(parts) == 1:
1972
+ return parts[0]
1973
+ return ("any", parts) if all(parts) else None
1974
+
1975
+ def p_and(strict):
1976
+ parts = [p_not(strict)]
1977
+ while peek() == "&&":
1978
+ take()
1979
+ parts.append(p_not(strict))
1980
+ if len(parts) == 1:
1981
+ return parts[0]
1982
+ known = [x for x in parts if x]
1983
+ if not known or (strict and len(known) < len(parts)):
1984
+ return None
1985
+ return ("all", known) if len(known) > 1 else known[0]
1986
+
1987
+ def p_not(strict):
1988
+ t = peek()
1989
+ if t == "!":
1990
+ take()
1991
+ if peek() not in ("(", "!") and peek() is not None:
1992
+ return self._os_atom(take(), True)
1993
+ x = p_not(True)
1994
+ return ("not", x) if x else None
1995
+ if t == "(":
1996
+ take()
1997
+ x = p_or(strict)
1998
+ if peek() == ")":
1999
+ take()
2000
+ return x
2001
+ if t is None:
2002
+ return None
2003
+ return self._os_atom(take(), False)
2004
+
2005
+ try:
2006
+ r = p_or(False)
2007
+ except RecursionError:
2008
+ return None
2009
+ return r if pos[0] == len(toks) else None
2010
+
2011
+ def _os_atom(self, expr: str, neg: bool):
2012
+ from ...platforms import _plat_atom
2013
+ m = re.match(r"(os|canImport|targetEnvironment)\s*\(\s*([\w.]+)\s*\)$", expr)
2014
+ if not m:
2015
+ return None
2016
+ fn, arg = m.groups()
2017
+ if fn == "os":
2018
+ a = self._apple(OS_PLATFORM.get(arg))
2019
+ elif fn == "canImport":
2020
+ if arg in APPLE_ONLY or arg == "FoundationNetworking":
2021
+ apple = ("any", [_plat_atom(p) for p in ("macos", "ios", "tvos", "watchos", "visionos")])
2022
+ return apple if (arg in APPLE_ONLY) != neg else ("not", apple)
2023
+ plat = IMPORT_PLATFORM.get(arg)
2024
+ if isinstance(plat, tuple): # UIKit: the iOS family, and the Mac when the app builds for Catalyst
2025
+ a = ("any", [self._apple(p) for p in plat] + ([self._apple("catalyst")] if self.mac in ("catalyst", "both") else []))
2026
+ elif plat == "macos" and self.mac == "catalyst":
2027
+ a = ("atom", "unknown_on", "macos") # AppKit in a Catalyst-only app: may or may not import
2028
+ else:
2029
+ a = _plat_atom(plat) if plat else None
2030
+ else: # Mac Catalyst: the iOS app built for macOS; simulator: not a platform
2031
+ a = self._apple("catalyst") if arg == "macCatalyst" else None
2032
+ if a is None:
2033
+ return None
2034
+ return ("not", a) if neg else a
2035
+
2036
+ def _apple(self, plat: str | None):
2037
+ """Atom of an Apple platform name for this project (#74): `os(macOS)` is the AppKit target (never true in a
2038
+ Catalyst-only app), `os(iOS)` is also true on the Mac when the app builds for Catalyst, and
2039
+ `targetEnvironment(macCatalyst)` is the Mac only then; with both a native and a Catalyst Mac build those are
2040
+ unknown on macOS. tvOS, watchOS and visionOS are their own targets."""
2041
+ from ...platforms import _plat_atom
2042
+ if plat is None:
2043
+ return None
2044
+ unk = ("atom", "unknown_on", "macos")
2045
+ if plat == "macos":
2046
+ return {"catalyst": ("atom", "never", None), "both": unk}.get(self.mac, _plat_atom("macos"))
2047
+ if plat == "ios":
2048
+ return {"catalyst": ("any", [_plat_atom("ios"), _plat_atom("macos")]),
2049
+ "both": ("any", [_plat_atom("ios"), unk])}.get(self.mac, _plat_atom("ios"))
2050
+ if plat == "catalyst":
2051
+ return {"catalyst": _plat_atom("macos"), "both": unk}.get(self.mac, ("atom", "never", None))
2052
+ return _plat_atom(plat)
2053
+
2054
+ # ------------------------------------------------------------------ resolution
2055
+ def _member(self, cls: Decl | None, name: str, depth: int = 0) -> list[Decl]:
2056
+ if cls is None:
2057
+ return []
2058
+ hit = self.members.get(cls.fqn, {}).get(name)
2059
+ if hit:
2060
+ return hit
2061
+ if depth > 6:
2062
+ return []
2063
+ if "extension-only" not in cls.modifiers:
2064
+ # `extension Models.Notification.NotificationType { ... }` (module-qualified) extends the nested type
2065
+ for e in self._qualified_ext().get(cls.fqn, []):
2066
+ hit = self.members.get(e.fqn, {}).get(name)
2067
+ if hit:
2068
+ return hit
2069
+ for s in cls.supers:
2070
+ sc = self.types.get(s)
2071
+ if sc is not None and sc is not cls:
2072
+ r = self._member(sc, name, depth + 1)
2073
+ if r:
2074
+ return r
2075
+ return []
2076
+
2077
+ def _qualified_ext(self) -> dict:
2078
+ """Extensions written with a qualified name (`extension Models.Notification.NotificationType`) by the fqn of
2079
+ the project type they extend (`Notification.NotificationType`)."""
2080
+ if getattr(self, "_qext", None) is None:
2081
+ self._qext = defaultdict(list)
2082
+ real = [d for d in self.types.values() if "extension-only" not in d.modifiers]
2083
+ for e in self.types.values():
2084
+ if "extension-only" in e.modifiers and "." in e.fqn:
2085
+ for t in real:
2086
+ if e.fqn.endswith("." + t.fqn):
2087
+ self._qext[t.fqn].append(e)
2088
+ return self._qext
2089
+
2090
+ def _resolve_calls(self):
2091
+ for owner, name, recv, line, sf, decl, labels, trailing in self.calls:
2092
+ self._how = self._recv = None
2093
+ self._at = (sf, line)
2094
+ targets = [t for t in self._targets(name, recv, decl, labels, trailing) if self._sees(t, True)]
2095
+ how = {"binding": self._how} if self._how else {}
2096
+ # the receiver's type when the member was found on an ancestor: `impact Sub.m` narrows by it (#62)
2097
+ if self._recv is not None and any(t.kind != "class" and t.cls and t.cls != self._recv.fqn for t in targets):
2098
+ how["recv"] = [self._recv.id]
2099
+ if self._how == "candidate":
2100
+ how["candidates"] = len(targets)
2101
+ if not targets:
2102
+ self.st["calls_unresolved"] += 1
2103
+ continue
2104
+ done = False
2105
+ for t in targets[:MAX_CANDIDATES if self._how == "candidate" else 3]:
2106
+ if t.kind == "class":
2107
+ hit = self._first_variants([m for m in self.members.get(t.fqn, {}).get("init", [])
2108
+ if self._fits(m.id, labels, trailing) and self._sees(m)])
2109
+ if "extension-only" in t.modifiers and not hit:
2110
+ # a type the project only extends (String, Data, URL, JSONDecoder, ...): the call uses one of
2111
+ # the SDK's initializers, not a project one
2112
+ self.st["calls_sdk_initializer"] += 1
2113
+ continue
2114
+ self.b.add_edge(owner, t.id, "INSTANTIATES", sf.rel, line, HEURISTIC,
2115
+ **self.branch_of.get((owner, line, name), {}))
2116
+ for m in hit:
2117
+ self.b.add_edge(owner, m.id, "CALLS", sf.rel, line, HEURISTIC)
2118
+ else:
2119
+ self.b.add_edge(owner, t.id, "CALLS", sf.rel, line, HEURISTIC, **how, **self._accessor_attr(decl, line))
2120
+ if how:
2121
+ self.st["calls_by_name_only" if self._how == "name" else "call_candidate_edges"] += 1
2122
+ done = True
2123
+ self.st["calls_resolved" if done else "calls_unresolved"] += 1
2124
+
2125
+ def _prop_member(self, cls: Decl | None, name: str, depth: int = 0) -> list[Decl]:
2126
+ """Property nodes named `name` of a type, its extensions and its supertypes (protocol extensions too)."""
2127
+ if cls is None or name in self.stored_in.get(cls.fqn, ()):
2128
+ return []
2129
+ hit = self.props.get(cls.fqn, {}).get(name)
2130
+ if hit:
2131
+ return hit
2132
+ if depth > 6:
2133
+ return []
2134
+ for e in self._qualified_ext().get(cls.fqn, []) if "extension-only" not in cls.modifiers else ():
2135
+ if name in self.stored_in.get(e.fqn, ()):
2136
+ return []
2137
+ hit = self.props.get(e.fqn, {}).get(name)
2138
+ if hit:
2139
+ return hit
2140
+ for s in cls.supers:
2141
+ sc = self.types.get(s)
2142
+ if sc is not None and sc is not cls:
2143
+ r = self._prop_member(sc, name, depth + 1)
2144
+ if r:
2145
+ return r
2146
+ return []
2147
+
2148
+ def _prop_targets(self, name: str, recv: str | None, decl: Decl | None) -> list[Decl]:
2149
+ """Property nodes a read reaches, with the receiver rules of method calls (#70, #83): a known receiver type
2150
+ binds exactly or not at all; an unknown one only by a name that no stored / SDK property shares."""
2151
+ cls = self._encl_type(decl)
2152
+ if recv is None or recv in ("self", "Self", "super"):
2153
+ own = self._prop_member(cls, name)
2154
+ want = True if recv == "Self" else False if recv in ("self", "super") else \
2155
+ bool(decl is not None and decl.kind != "class" and _static(decl))
2156
+ return [d for d in own if _static(d) == want] or (own if recv is None else [])
2157
+ kind, tname = self._recv_type(recv, decl, cls)
2158
+ if kind in ("type", "meta"):
2159
+ return [d for d in self._prop_member(self._type(tname), name) if _static(d) == (kind == "meta")]
2160
+ if kind == "sdk":
2161
+ tc = self.types.get(tname) if tname else None
2162
+ if tc is not None and "extension-only" in tc.modifiers:
2163
+ own = self._prop_member(tc, name)
2164
+ if own:
2165
+ return own
2166
+ if tname is None and (name in self.stored_names or name in COMMON_PROPS):
2167
+ return [] # `conn.data.database`: some SDK value's own (stored) property
2168
+ cands = [d for d in self._all_props(name) if d.cls in self.types
2169
+ and "extension-only" in self.types[d.cls].modifiers and not _static(d)
2170
+ and (not self._certain or d.cls not in CONCRETE_SDK or d.cls == tname)]
2171
+ return cands if len(cands) == 1 else []
2172
+ if name in self.stored_names or name in COMMON_PROPS:
2173
+ return []
2174
+ cands = self._first_variants([d for d in self._all_props(name) if not _static(d)])
2175
+ if not cands or len(cands) > MAX_CANDIDATES:
2176
+ return []
2177
+ self._how = "name" if len(cands) == 1 else "candidate"
2178
+ return cands
2179
+
2180
+ def _all_props(self, name: str) -> list[Decl]:
2181
+ return [d for by in self.props.values() for d in by.get(name, ()) if self._sees(d)]
2182
+
2183
+ def _resolve_props(self):
2184
+ """CALLS edges for reads of computed / lazy properties and writes of computed / observed ones (`property`:
2185
+ `read` | `write`); a read of a stored property with observers runs no code of it."""
2186
+ for owner, name, recv, line, sf, decl, write in self.prop_refs:
2187
+ if name not in self.prop_names or write == "keypath":
2188
+ self._field_ref(owner, name, recv, line, sf, decl, write)
2189
+ continue
2190
+ self._how = None
2191
+ self._at = (sf, line)
2192
+ targets = [t for t in self._prop_targets(name, recv, decl) if self._sees(t, True)]
2193
+ how = {"binding": self._how} if self._how else {}
2194
+ if self._how == "candidate":
2195
+ how["candidates"] = len(targets)
2196
+ done = False
2197
+ # an in-place mutation (`items.append(x)`, `&x`, `$x`) fires an observer's didSet and, on a lazy var,
2198
+ # still runs its initializer first; only a plain assignment skips the lazy initializer
2199
+ for t in targets[:MAX_CANDIDATES]:
2200
+ if t.id == owner or ("observed" in t.modifiers and not write) or ("lazy" in t.modifiers and write is True):
2201
+ continue
2202
+ self.b.add_edge(owner, t.id, "CALLS", sf.rel, line, HEURISTIC, property="write" if write else "read",
2203
+ **how, **self._accessor_attr(decl, line))
2204
+ done = True
2205
+ self.st["property_refs_resolved" if done else "property_refs_unresolved"] += 1
2206
+ if not done and name in self.field_names:
2207
+ self._field_ref(owner, name, recv, line, sf, decl, write)
2208
+
2209
+ def _field_member(self, cls: Decl | None, name: str, depth: int = 0) -> str | None:
2210
+ """The field node of a stored property `name` of a type or its superclasses."""
2211
+ if cls is None or depth > 6:
2212
+ return None
2213
+ fid = self.fields.get(cls.fqn, {}).get(name)
2214
+ if fid:
2215
+ return fid
2216
+ for s in cls.supers:
2217
+ sc = self.types.get(s)
2218
+ if sc is not None and sc is not cls:
2219
+ r = self._field_member(sc, name, depth + 1)
2220
+ if r:
2221
+ return r
2222
+ return None
2223
+
2224
+ def _field_ref(self, owner, name, recv, line, sf, decl, write):
2225
+ """READS_PROP / WRITES_PROP to a stored property (#88): `self.x` / a bare `x` inside its type, or `v.x`
2226
+ where the type of `v` is known. An unknown receiver binds nothing (stored names are far too common)."""
2227
+ cls = self._encl_type(decl)
2228
+ fid = None
2229
+ conf, extra = RESOLVED, {}
2230
+ if write == "keypath" and recv == "\\":
2231
+ # `\.items` (root inferred by the compiler): the one type with a stored `items`, by name
2232
+ # standard-library members (`enumerated()`'s `\\.offset`, a dictionary's `\\.key`) are never bound by name
2233
+ cands = [] if name in KEYPATH_STDLIB else \
2234
+ [f[name] for f in self.fields.values() if name in f and not name.startswith("_")]
2235
+ fid = cands[0] if len(cands) == 1 else None
2236
+ conf, extra = HEURISTIC, {"binding": "name"}
2237
+ elif write == "keypath":
2238
+ fid = self._field_member(self._type(recv[1:].split(".")[-1]), name)
2239
+ elif recv is None or recv in ("self", "super"):
2240
+ fid = self._field_member(cls, name)
2241
+ elif recv not in ("Self",):
2242
+ self._at = (sf, line)
2243
+ kind, tname = self._recv_type(recv, decl, cls)
2244
+ if kind == "type":
2245
+ fid = self._field_member(self._type(tname), name)
2246
+ if fid is None or fid == owner:
2247
+ self.st["stored_property_refs_unresolved"] += 1
2248
+ return
2249
+ via = write if isinstance(write, str) else None
2250
+ write = bool(write) and via != "keypath" # a key path names the property; whether it writes is unknown
2251
+ self.b.add_edge(owner, fid, "WRITES_PROP" if write else "READS_PROP", sf.rel, line, conf, **extra,
2252
+ **({"receiver": "self"} if recv in (None, "self", "super") else {"receiver": recv[:40]}),
2253
+ **({"via": via} if via else {}),
2254
+ **({"storage": "wrapper"} if name.startswith("_") else {}),
2255
+ **self._accessor_attr(decl, line))
2256
+ self.st["stored_property_writes" if write else "stored_property_reads"] += 1
2257
+
2258
+ @staticmethod
2259
+ def _accessor_attr(decl: Decl | None, line: int) -> dict:
2260
+ """{"accessor": "didSet"} for a reference inside an explicit accessor of a property node."""
2261
+ for a, b, name in (decl.ranges if decl is not None else ()):
2262
+ if a <= line <= b:
2263
+ return {"accessor": name}
2264
+ return {}
2265
+
2266
+ @staticmethod
2267
+ def _first_variants(ds: list) -> list:
2268
+ """Of the per-`#if` definitions of one overload (`init`, `init@42`, `init@48` that fit the call), the first:
2269
+ the platform pass links its sibling variants (attrs.platform_variant_of)."""
2270
+ best: dict = {}
2271
+ for d in ds:
2272
+ k = d.id.split("@")[0]
2273
+ if k not in best or d.line < best[k].line:
2274
+ best[k] = d
2275
+ return list(best.values())
2276
+
2277
+ def _fits(self, did: str, labels: tuple, trailing: bool) -> bool:
2278
+ """Some signature of `did` accepts the call's argument labels (unknown signature: yes). Parameters with a
2279
+ default value, closures and variadics may be left out; a trailing closure fills a closure parameter."""
2280
+ sigs = self.sigs.get(did)
2281
+ if not sigs:
2282
+ return True
2283
+ for sig in sigs:
2284
+ i = 0
2285
+ ok = True
2286
+ for k, (lab, opt) in enumerate(sig):
2287
+ if i < len(labels) and labels[i] == lab:
2288
+ i += 1
2289
+ elif trailing and i == len(labels) and all(o for _l, o in sig[k + 1:]):
2290
+ trailing = False # the trailing closure is this parameter (a closure typealias)
2291
+ elif not opt:
2292
+ ok = False
2293
+ break
2294
+ if ok and i == len(labels):
2295
+ return True
2296
+ return False
2297
+
2298
+ def _encl_type(self, decl: Decl | None) -> Decl | None:
2299
+ if decl is None:
2300
+ return None
2301
+ if decl.kind == "class":
2302
+ return decl
2303
+ return self.types.get(decl.cls) if decl.cls else None
2304
+
2305
+ @staticmethod
2306
+ def _selector(name: str, labels: tuple, trailing: bool) -> str:
2307
+ return f"{name}({''.join(l + ':' for l in labels)}{'_:' if trailing and labels else ''})" if labels or not trailing \
2308
+ else f"{name}(_:)"
2309
+
2310
+ @staticmethod
2311
+ def _segments(recv: str) -> list[str]:
2312
+ """`a.b(x).c[0]!` -> ['a', 'b(x)', 'c[0]!'] (top-level dots; of a binary expression only the right operand)."""
2313
+ depth, quote, cut, parts = 0, False, 0, []
2314
+ txt = recv.strip()
2315
+ for i, ch in enumerate(txt):
2316
+ if ch == '"':
2317
+ quote = not quote
2318
+ if quote:
2319
+ continue
2320
+ if ch in "([{":
2321
+ depth += 1
2322
+ elif ch in ")]}":
2323
+ depth -= 1
2324
+ elif depth == 0:
2325
+ if ch == "." and not (i > 0 and txt[i - 1].isdigit()):
2326
+ parts.append(txt[cut:i])
2327
+ cut = i + 1
2328
+ elif ch in "+-*/%<>=&|?" and i > 0 and txt[i - 1] in " \t\n" and re.match(r"[+\-*/%<>=&|?]+\s", txt[i:]):
2329
+ parts, cut = [], i + len(re.match(r"[+\-*/%<>=&|?]+\s*", txt[i:]).group(0))
2330
+ parts.append(txt[cut:])
2331
+ return [p.strip() for p in parts if p.strip()]
2332
+
2333
+ def _type(self, name: str | None) -> Decl | None:
2334
+ """A project type by fqn or, for nested types (`StatusEditor.TextEditingService` recorded as
2335
+ `TextEditingService`), by its unique simple name."""
2336
+ if not name:
2337
+ return None
2338
+ t = self.types.get(name)
2339
+ if t is not None:
2340
+ return t
2341
+ cs = {d.fqn: d for d in self.by_name.get(name.split(".")[-1], []) if d.kind == "class"}
2342
+ return next(iter(cs.values())) if len(cs) == 1 else None
2343
+
2344
+ def _sdk_or_unknown(self, tn: str) -> tuple[str, str | None]:
2345
+ """A type name cg could not resolve to one project type: an SDK type unless the project declares a type of
2346
+ that simple name (several nested ones, `Configuration`): then unknown."""
2347
+ if any(d.kind == "class" and "extension-only" not in d.modifiers for d in self.by_name.get(tn.split(".")[-1], [])):
2348
+ return "unknown", None
2349
+ return "sdk", tn
2350
+
2351
+ def _local(self, decl: Decl | None, ident: str) -> str | None:
2352
+ """The type of a function's local variable at the call being resolved: the nearest declaration of the name
2353
+ above the call (`let g = SlotGate<String>()`, `let p = Path()`, `var inside = false`, `let n: Int = ...`,
2354
+ `var puzzle = Puzzle(archivableCubes: puzzle)` over the parameter); None when that declaration's type is not
2355
+ readable or there is none."""
2356
+ sf, line = getattr(self, "_at", (None, 0))
2357
+ if decl is None or sf is None or decl.kind == "class" or sf.rel != decl.file:
2358
+ return None
2359
+ cache = self.__dict__.setdefault("_loc_cache", {})
2360
+ key = (decl.id, decl.file, decl.line)
2361
+ if key not in cache:
2362
+ txt = "\n".join(sf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end])
2363
+ out: dict[str, list] = defaultdict(list)
2364
+ found = []
2365
+ for m in LOCAL_DECL.finditer(txt):
2366
+ nm, ann, init, lit = m.group(1), m.group(2), m.group(3), m.group(4)
2367
+ tn = ann.split(".")[-1] if ann else init
2368
+ if tn is None and lit:
2369
+ tn = next(t for t, rx in LITERAL_TYPES if re.match(rx, lit))
2370
+ found.append((m.start(), nm, tn))
2371
+ for m in CLOSURE_PARAM.finditer(txt): # `Path { p in p.close() }`: p is a Path
2372
+ found.append((m.start(), m.group(2), CLOSURE_PARAM_TYPES[m.group(1)]))
2373
+ for pos, nm, tn in sorted(found, key=lambda x: x[0]):
2374
+ out[nm].append((decl.line + txt.count("\n", 0, pos), tn))
2375
+ cache[key] = out
2376
+ best = None
2377
+ for ln, tn in cache[key].get(ident, ()):
2378
+ if ln <= line:
2379
+ best = tn
2380
+ return best
2381
+
2382
+ def _recv_type(self, recv: str, decl: Decl | None, cls: Decl | None) -> tuple[str, str | None]:
2383
+ """What a receiver expression is: ("type", T) a value of project type T, ("meta", T) the project type T itself
2384
+ (static members), ("sdk", T | None) a value of an SDK type (T when known; modifier chains on SDK views,
2385
+ `Font.body`, `UIApplication.shared`), ("unknown", None). `a?.b` / `a!.b` read as `a.b`."""
2386
+ segs = self._segments(recv)
2387
+ self._certain = False
2388
+ if not segs:
2389
+ return "unknown", None
2390
+ state: tuple[str, str | None] = ("unknown", None)
2391
+ for i, seg in enumerate(segs):
2392
+ seg = seg.rstrip("!?")
2393
+ m = re.match(r"^([A-Za-z_]\w*)\s*(.*)$", seg, re.S)
2394
+ if not m:
2395
+ return "unknown", None
2396
+ ident, rest = m.group(1), m.group(2).strip()
2397
+ called = rest.startswith(("(", "{"))
2398
+ sub = rest.startswith("[")
2399
+ if i == 0:
2400
+ if ident in ("self", "Self", "super") and not rest:
2401
+ if cls is None:
2402
+ return "unknown", None
2403
+ state = ("type", cls.fqn) if "extension-only" not in cls.modifiers else ("sdk", cls.fqn)
2404
+ continue
2405
+ if ident[:1].isupper():
2406
+ t = self._type(ident)
2407
+ if t is not None and "extension-only" not in t.modifiers:
2408
+ state = ("type", t.fqn) if called else ("meta", t.fqn)
2409
+ else: # an SDK type, its initializer or a value built from it
2410
+ state = ("sdk", ident) if t is not None else self._sdk_or_unknown(ident)
2411
+ if called and len(segs) == 1 and state[0] == "sdk":
2412
+ self._certain = True # `UIGraphicsPDFRenderer(bounds: r).pdfData { }`: that type
2413
+ if sub:
2414
+ state = ("unknown", None)
2415
+ continue
2416
+ if called: # a free function's result
2417
+ return "unknown", None
2418
+ ltn = self._local(decl, ident)
2419
+ tn = ltn or (decl.types.get(ident) if decl is not None else None) or \
2420
+ (cls.types.get(ident) if cls is not None else None)
2421
+ if tn is None or sub:
2422
+ return "unknown", None
2423
+ t = self._type(tn)
2424
+ state = ("type", t.fqn) if t is not None and "extension-only" not in t.modifiers else \
2425
+ ("sdk", tn) if t is not None else self._sdk_or_unknown(tn)
2426
+ if ltn and state[0] == "sdk":
2427
+ if len(segs) > 1: # a property chain off a local SDK value may reach project types
2428
+ return "unknown", None
2429
+ self._certain = True # `var inside = false`, `let p = Path()`: that type, nothing else
2430
+ continue
2431
+ kind, tname = state
2432
+ if kind == "sdk":
2433
+ if sub:
2434
+ return "unknown", None
2435
+ # `Font.body`, `UIApplication.shared`: a static property of an SDK type is a value of that type;
2436
+ # anything further down a chain (`Text("x").font(...)`) is some SDK value
2437
+ state = ("sdk", tname if not called and i == 1 and segs[0][:1].isupper() else None)
2438
+ continue
2439
+ if kind in ("type", "meta") and not called and not sub:
2440
+ tc = self.types.get(tname)
2441
+ tn = tc.types.get(ident) if tc is not None else None
2442
+ t = self._type(tn)
2443
+ if t is not None and "extension-only" not in t.modifiers:
2444
+ state = ("type", t.fqn)
2445
+ continue
2446
+ if tn:
2447
+ state = ("sdk", tn) if t is not None else self._sdk_or_unknown(tn)
2448
+ if state[0] == "unknown":
2449
+ return state
2450
+ continue
2451
+ return "unknown", None
2452
+ return state
2453
+
2454
+ def _fitting(self, ds: list, labels: tuple, trailing: bool) -> list:
2455
+ return [d for d in ds if self._fits(d.id, labels, trailing) and self._sees(d, True)]
2456
+
2457
+ def _dispatch_visible(self, d: Decl, caller: str, depth: int = 0) -> bool:
2458
+ """A member of a type outside the caller's modules that overrides or implements a member of a supertype the
2459
+ caller does see (an `EventMonitor` conformer in the tests): reached at run time through dynamic dispatch."""
2460
+ t = self.types.get(d.cls) if d.cls else None
2461
+ if t is None or depth > 4:
2462
+ return False
2463
+ for _s, st in self._supers(t):
2464
+ if st is None or st is t or "extension-only" in st.modifiers:
2465
+ continue
2466
+ if self.spm.sees(caller, st.file) and (d.name in self.members.get(st.fqn, {})
2467
+ or d.name in self.props.get(st.fqn, {})
2468
+ or d.name in (self.b.nodes[st.id].attrs.get("property_requirements") or ())):
2469
+ return True
2470
+ if self._dispatch_visible(Decl(d.id, d.kind, d.name, d.fqn, d.file, d.line, d.end, st.fqn), caller, depth + 1):
2471
+ return True
2472
+ return False
2473
+
2474
+ def _super_type(self, name: str) -> Decl | None:
2475
+ """The project type a supertype name means: the type of that (qualified, `Module.`-prefixed) name, else the
2476
+ one nested type of that short name."""
2477
+ q = name
2478
+ while q:
2479
+ if q in self.types:
2480
+ return self.types[q]
2481
+ q = q.split(".", 1)[1] if "." in q else None # `Module.Type`: drop the module
2482
+ if getattr(self, "_nested", None) is None:
2483
+ self._nested = defaultdict(list)
2484
+ for d in self.types.values():
2485
+ if "." in d.fqn and "extension-only" not in d.modifiers:
2486
+ self._nested[d.fqn.rsplit(".", 1)[1]].append(d)
2487
+ hit = self._nested.get(name.rsplit(".", 1)[-1]) or []
2488
+ return hit[0] if len(hit) == 1 else None
2489
+
2490
+ def _supers(self, t: Decl) -> list[tuple[str, Decl | None]]:
2491
+ """(name, project type or None) per supertype of `t`; a qualified name written in the inheritance clause
2492
+ (`final class Spy: StatusEditor.AutocompleteService.Client, StatusEditor.PostingService.Client`) is resolved
2493
+ as written (#90)."""
2494
+ quals = self._qsup.get(t.fqn) or []
2495
+ short = {q.rsplit(".", 1)[1] for q in quals}
2496
+ names = list(quals) + [x for x in dict.fromkeys(t.supers) if x not in short]
2497
+ return [(x, self._super_type(x)) for x in names]
2498
+
2499
+ def _sees(self, d: Decl, count: bool = False) -> bool:
2500
+ """May the call / read being resolved name `d`? Code in a SwiftPM target sees its own module and the targets
2501
+ it depends on, never an app, extension, preview or test target's declarations (#90). A type the project only
2502
+ extends is the SDK's, visible everywhere (its members are not)."""
2503
+ if not self.spm or self._at is None:
2504
+ return True
2505
+ if d.kind == "class" and "extension-only" in d.modifiers:
2506
+ return True
2507
+ caller = self._at[0].rel
2508
+ if self.spm.sees(caller, d.file) or self._dispatch_visible(d, caller):
2509
+ return True
2510
+ if count:
2511
+ self.st["candidates_outside_module"] += 1
2512
+ return False
2513
+
2514
+ def _targets(self, name: str, recv: str | None, decl: Decl | None, labels: tuple = (), trailing: bool = False) -> list[Decl]:
2515
+ """Project declarations a call reaches: the full selector (name, argument labels, arity) has to fit;
2516
+ static members only through the type, instance members only through a value (#70)."""
2517
+ cls = self._encl_type(decl)
2518
+ if recv is None or recv in ("self", "Self", "super"):
2519
+ if cls is not None:
2520
+ own = [d for d in self._member(cls, name) if d.kind != "class"]
2521
+ if own:
2522
+ self._recv = cls
2523
+ fit = self._fitting(own, labels, trailing)
2524
+ want = True if recv == "Self" else False if recv in ("self", "super") else \
2525
+ bool(decl is not None and decl.kind != "class" and _static(decl))
2526
+ return [d for d in fit if _static(d) == want] or (fit if recv is None else
2527
+ [d for d in fit if d.name == "init"])
2528
+ if name[:1].isupper():
2529
+ t = self.types.get(name)
2530
+ return [t] if t is not None else []
2531
+ cands = self._fitting([d for d in self.by_name.get(name, []) if d.kind == "function"], labels, trailing)
2532
+ if len(cands) > 1 and len({d.id.split("@")[0] for d in cands}) == 1:
2533
+ # one function defined per `#if os(...)` branch: the call reaches the base definition, and the
2534
+ # platform pass links its sibling variants (attrs.platform_variant_of)
2535
+ return [min(cands, key=lambda d: ("@" in d.id, d.line))]
2536
+ return cands if len(cands) == 1 else []
2537
+ kind, tname = self._recv_type(recv, decl, cls)
2538
+ if kind in ("type", "meta"):
2539
+ tc = self._type(tname)
2540
+ self._recv = tc
2541
+ ms = [d for d in self._member(tc, name) if d.kind != "class"] if tc is not None else []
2542
+ ms = [d for d in ms if _static(d) == (kind == "meta")] or \
2543
+ ([] if kind == "type" else [d for d in ms if d.name == "init"])
2544
+ return self._fitting(ms, labels, trailing)
2545
+ sel = self._selector(name, labels, trailing)
2546
+ sdk_sel = sel in SDK_SELECTORS or (name in STDLIB_METHODS and all(lab in STDLIB_LABELS for lab in labels))
2547
+ if kind == "sdk" and re.fullmatch(r"[A-Z]\w*", recv.strip()):
2548
+ # `Styleguide.registerFonts()`: a module-qualified free function (the module is a target folder,
2549
+ # `Sources/Styleguide/`)
2550
+ fs = self._fitting([d for d in self.by_name.get(name, []) if d.kind == "function"
2551
+ and f"/{recv.strip()}/" in "/" + d.file], labels, trailing)
2552
+ if fs:
2553
+ return self._first_variants(fs)
2554
+ if kind == "sdk":
2555
+ # an SDK value: only members the project declares in an extension of an SDK type (of that type when
2556
+ # known, else of any: `Group { ... }.withEnvironments()` reaches `extension View`); never a selector
2557
+ # the SDK itself has (`.sorted(by:)`, `.accessibilityIdentifier(_:)`)
2558
+ if sdk_sel:
2559
+ self.st["calls_sdk_selector"] += 1
2560
+ return []
2561
+ tc = self.types.get(tname) if tname else None
2562
+ if tc is not None and "extension-only" in tc.modifiers:
2563
+ own = self._fitting([d for d in self._member(tc, name) if d.kind != "class"], labels, trailing)
2564
+ if own:
2565
+ return own
2566
+ cands = [d for d in self.by_name.get(name, []) if d.kind == "method" and d.cls in self.types
2567
+ and "extension-only" in self.types[d.cls].modifiers and "static" not in d.modifiers
2568
+ and (not self._certain or d.cls not in CONCRETE_SDK or d.cls == tname)]
2569
+ cands = self._fitting(cands, labels, trailing)
2570
+ # short unlabelled selectors (`.run()`, `.get(_:)`) are too often the SDK type's own member
2571
+ return cands if len(cands) == 1 and (any(lab != "_" for lab in labels) or len(name) > 3) else []
2572
+ # unknown receiver: the selector decides
2573
+ if sdk_sel: # `xs.first(where:)`, `continuation.resume(returning:)`: the SDK's
2574
+ self.st["calls_sdk_selector"] += 1
2575
+ return []
2576
+ labelled = any(lab != "_" for lab in labels)
2577
+ cands = [d for d in self.by_name.get(name, []) if d.kind == "method" and "static" not in d.modifiers
2578
+ and "class" not in d.modifiers]
2579
+ cands = self._first_variants(self._fitting(cands, labels, trailing))
2580
+ if not cands or not (labelled or len(name) > 3):
2581
+ return []
2582
+ if len(cands) > MAX_CANDIDATES:
2583
+ # `update()` on twenty app types: no useful candidate set
2584
+ self.st["calls_too_ambiguous"] += 1
2585
+ return []
2586
+ # bound by the selector alone (receiver type unknown): one fitting method is a `name` binding, several are
2587
+ # `candidate` edges to each (#83 item 6); both are left out of divergence, `cg tests` / impact flag candidates
2588
+ self._how = "name" if len(cands) == 1 else "candidate"
2589
+ return cands
2590
+
2591
+ def _hierarchy(self):
2592
+ for d in list(self.decls.values()):
2593
+ if d.kind != "class":
2594
+ continue
2595
+ sups = self._supers(d)
2596
+ ext = [s for s, sc in sups if sc is None or "extension-only" in sc.modifiers]
2597
+ if ext and self.b.nodes[d.id].attrs.get("swift_kind") == "class":
2598
+ # an SDK superclass (`NSPopover`, `UIImageView`): members and initializers it gives are not in the graph
2599
+ self.b.nodes[d.id].attrs["external_supers"] = ext[:4]
2600
+ for s, sc in sups:
2601
+ if sc is None:
2602
+ if s in LIFECYCLE_BASES or s == "App":
2603
+ self._entry_class(d, "ui_page" if s == "UIViewController" else "main", f"{s} conformance")
2604
+ continue
2605
+ kind = "IMPLEMENTS" if self.b.nodes[sc.id].attrs.get("swift_kind") == "protocol" else "EXTENDS"
2606
+ self.b.add_edge(d.id, sc.id, kind, d.file, d.line, HEURISTIC)
2607
+ for nm, ms in self.members.get(d.fqn, {}).items():
2608
+ for base in self.members.get(sc.fqn, {}).get(nm, []):
2609
+ for m in ms:
2610
+ if m is not base:
2611
+ ek = "IMPLEMENTED_BY" if kind == "IMPLEMENTS" else "OVERRIDDEN_BY"
2612
+ self.b.add_edge(base.id, m.id, ek, m.file, m.line, HEURISTIC)
2613
+
2614
+ def _entry_class(self, d: Decl, kind: str, why: str):
2615
+ n = self.b.nodes[d.id]
2616
+ if n.entry_kind == kind == "main": # @main App: counted once
2617
+ self.st["entries_main"] -= 1
2618
+ n.entry_kind = n.entry_kind or kind
2619
+ n.attrs["entry_reason"] = why
2620
+ for nm, ms in self.members.get(d.fqn, {}).items():
2621
+ if LIFECYCLE.match(nm):
2622
+ for m in ms:
2623
+ self.b.add_edge(d.id, m.id, "REFERENCES_FN", m.file, m.line, EXACT, how="framework lifecycle")
2624
+ self.st[f"entries_{kind}"] += 1
2625
+
2626
+ # ------------------------------------------------------------------ Moya / Fluent (whole-file facts)
2627
+ @staticmethod
2628
+ def _block(txt: str, start: int) -> str:
2629
+ """The brace-balanced block whose `{` is at or after `start`."""
2630
+ i = txt.find("{", start)
2631
+ if i < 0:
2632
+ return ""
2633
+ depth = 0
2634
+ for j in range(i, len(txt)):
2635
+ if txt[j] == "{":
2636
+ depth += 1
2637
+ elif txt[j] == "}":
2638
+ depth -= 1
2639
+ if depth == 0:
2640
+ return txt[i:j + 1]
2641
+ return txt[i:]
2642
+
2643
+ def _prop_block(self, body: str, name: str) -> str | None:
2644
+ m = re.search(r"\bvar\s+" + name + r"\s*:\s*[\w.]+\s*\{", body)
2645
+ return self._block(body, m.start()) if m else None
2646
+
2647
+ def _per_case(self, block: str | None, value_rx: str) -> tuple[dict, str | None]:
2648
+ """`switch self { case .a, .b(let x): return "..." }` -> ({case: value}, default value)."""
2649
+ if not block:
2650
+ return {}, None
2651
+ out, default = {}, None
2652
+ parts = re.split(r"\n\s*(case\s+[^\n:]*?:|default\s*:)", block)
2653
+ if len(parts) == 1:
2654
+ m = re.search(value_rx, block)
2655
+ return {}, (m.group(1) if m else None)
2656
+ for head, body in zip(parts[1::2], parts[2::2]):
2657
+ m = re.search(value_rx, body)
2658
+ if not m:
2659
+ continue
2660
+ if head.startswith("default"):
2661
+ default = m.group(1)
2662
+ continue
2663
+ for c in re.findall(r"\.(\w+)", head.split("case", 1)[1]):
2664
+ out.setdefault(c, m.group(1))
2665
+ return out, default
2666
+
2667
+ def _moya_targets(self, sfiles):
2668
+ """Moya `TargetType` enums: baseURL + per-case path / method -> endpoint templates per case."""
2669
+ self.moya: dict[str, dict] = {} # enum name -> {"base", "cases": {case: (METHOD, path)}, decl}
2670
+ texts = {sf.rel: sf.src.decode("utf-8", "replace") for sf in sfiles if b"TargetType" in sf.src}
2671
+ names = set()
2672
+ for txt in texts.values():
2673
+ names.update(re.findall(r"\b(?:enum|extension|struct)\s+(\w+)\s*:[^{]*\bTargetType\b", txt))
2674
+ for name in sorted(names):
2675
+ body = ""
2676
+ for txt in texts.values():
2677
+ for m in re.finditer(r"\b(?:enum|extension)\s+" + name + r"\b[^{]*\{", txt):
2678
+ body += self._block(txt, m.start()) + "\n"
2679
+ d = self.types.get(name)
2680
+ if d is not None:
2681
+ sf = next((s for s in sfiles if s.rel == d.file), None)
2682
+ if sf is not None:
2683
+ lines = sf.src.decode("utf-8", "replace").split("\n")[d.line - 1:d.end]
2684
+ body += "\n".join(lines)
2685
+ cases = re.findall(r"^\s*case\s+(\w+(?:\s*\([^)]*\))?(?:\s*,\s*\w+(?:\s*\([^)]*\))?)*)\s*$", body, re.M)
2686
+ case_names = []
2687
+ for c in cases:
2688
+ case_names += [re.match(r"\w+", x.strip()).group(0) for x in re.split(r",(?![^(]*\))", c) if x.strip()]
2689
+ bb = self._prop_block(body, "baseURL") or ""
2690
+ mb = re.search(r'URL\s*\(\s*string\s*:\s*("(?:[^"\\]|\\.)*")', bb)
2691
+ base = template(mb.group(1)) if mb else "{baseURL}"
2692
+ paths, pdef = self._per_case(self._prop_block(body, "path"), r'("(?:[^"\\]|\\.)*")')
2693
+ meths, mdef = self._per_case(self._prop_block(body, "method"), r"\.(get|post|put|delete|patch|head|options)\b")
2694
+ out = {}
2695
+ for c in dict.fromkeys(case_names or list(paths)):
2696
+ p = paths.get(c) or pdef
2697
+ if p is None:
2698
+ continue
2699
+ out[c] = ((meths.get(c) or mdef or "get").upper(), template(p))
2700
+ if out:
2701
+ self.moya[name] = {"base": base, "cases": out, "decl": d}
2702
+ self.st["moya_targets"] += 1
2703
+ # provider variables: `let provider = MoyaProvider<GitHub>()` / `var api: MoyaProvider<GitHub>`
2704
+ self.moya_vars: dict[str, str] = {}
2705
+ for txt in (sf.src.decode("utf-8", "replace") for sf in sfiles if b"Provider<" in sf.src):
2706
+ for m in re.finditer(r"\b(\w+)\s*(?::\s*\w*Provider\s*<\s*(\w+)\s*>|=\s*\w*Provider\s*<\s*(\w+)\s*>)", txt):
2707
+ self.moya_vars.setdefault(m.group(1), m.group(2) or m.group(3))
2708
+
2709
+ def _moya_call(self, name, recv, al, owner, sf, line) -> bool:
2710
+ if not self.moya or name not in ("request", "requestPublisher", "requestWithProgress") or not al:
2711
+ return False
2712
+ arg = self.t(al[0][1]) if al[0][1] is not None else ""
2713
+ m = re.match(r"\s*(?:(\w+))?\.(\w+)", arg)
2714
+ if not m:
2715
+ return False
2716
+ case, tname = m.group(2), m.group(1)
2717
+ if tname is None:
2718
+ rv = re.sub(r"\.(rx|reactive)$", "", recv or "").split(".")[-1]
2719
+ tname = self.moya_vars.get(rv)
2720
+ targets = [tname] if tname in self.moya else [t for t, v in self.moya.items() if case in v["cases"]]
2721
+ if len(targets) != 1 or case not in self.moya[targets[0]]["cases"]:
2722
+ return False
2723
+ t = self.moya[targets[0]]
2724
+ meth, path = t["cases"][case]
2725
+ self.http.append({"src": owner, "method": meth, "url": join_path(t["base"], path), "client": "moya",
2726
+ "file": sf.rel, "line": line, "target": f"{targets[0]}.{case}"})
2727
+ self.st["moya_calls"] += 1
2728
+ return True
2729
+
2730
+ def _moya_endpoints(self):
2731
+ """Every Moya case is an endpoint of its TargetType, called or not (the enum -> http edge)."""
2732
+ for name, t in self.moya.items():
2733
+ d = t["decl"]
2734
+ if d is None:
2735
+ continue
2736
+ for case, (meth, path) in t["cases"].items():
2737
+ self.http.append({"src": d.id, "method": meth, "url": join_path(t["base"], path), "client": "moya",
2738
+ "file": d.file, "line": d.line, "target": f"{name}.{case}", "declared": True})
2739
+
2740
+ def _fluent_models(self, sfiles):
2741
+ """Fluent `Model` classes (`static let schema = "todos"`) -> table nodes (MAPS_TO_TABLE)."""
2742
+ self.fluent: dict[str, str] = {}
2743
+ if not any(b"Fluent" in sf.src for sf in sfiles):
2744
+ return
2745
+ bysrc = {sf.rel: sf.src.decode("utf-8", "replace").split("\n") for sf in sfiles if b"schema" in sf.src}
2746
+ for d in list(self.types.values()):
2747
+ if "Model" not in d.supers or d.file not in bysrc:
2748
+ continue
2749
+ body = "\n".join(bysrc[d.file][d.line - 1:d.end])
2750
+ m = re.search(r'static\s+(?:let|var)\s+schema\s*(?::\s*String)?\s*(?:=\s*|\{\s*(?:return\s+)?)"([^"]+)"', body)
2751
+ if not m:
2752
+ continue
2753
+ self.fluent[d.name] = m.group(1)
2754
+ tid = self.b.add_node("table", m.group(1), lang="sql", attrs={"via": "fluent"})
2755
+ self.b.add_edge(d.id, tid, "MAPS_TO_TABLE", d.file, d.line, EXACT, via="Fluent schema")
2756
+ self.st["fluent_models"] += 1
2757
+
2758
+ def _local_types(self, decl, sf) -> dict:
2759
+ """`let todo = Todo(...)`, `guard let todo = try await Todo.find(...)`, `let t: Todo = ...` in a function."""
2760
+ key = (decl.id, decl.line) if decl is not None else None
2761
+ if key is None:
2762
+ return {}
2763
+ cache = self.__dict__.setdefault("_lt_cache", {})
2764
+ if key not in cache:
2765
+ lines = sf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end]
2766
+ out = {}
2767
+ for m in re.finditer(r"\b(?:let|var)\s+(\w+)\s*(?::\s*(\w+))?\s*=\s*(?:try\s*[?!]?\s+)?(?:await\s+)?([A-Z]\w*)\s*[.(]",
2768
+ "\n".join(lines)):
2769
+ out.setdefault(m.group(1), m.group(2) or m.group(3))
2770
+ cache[key] = out
2771
+ return cache[key]
2772
+
2773
+ def _fluent_call(self, name, recv, owner, decl, sf, line, c):
2774
+ if not self.fluent or recv is None:
2775
+ return
2776
+ rv = re.sub(r"[?!]|\(.*\)$", "", recv).split(".")[-1].strip()
2777
+ if rv in self.fluent and name in ("query", "find"): # Todo.query(on:) / Todo.find(id, on:)
2778
+ whole = c
2779
+ while whole.parent is not None and whole.parent.type in ("navigation_expression", "call_expression",
2780
+ "call_suffix", "await_expression", "try_expression"):
2781
+ whole = whole.parent
2782
+ chain = self.t(whole)
2783
+ kind = "WRITES_TABLE" if re.search(r"\.(delete|update|set|create)\s*\(", chain) else "READS_TABLE"
2784
+ tid = f"table:{self.fluent[rv]}"
2785
+ self.b.add_edge(owner, tid, kind, sf.rel, line, RESOLVED, via=f"{rv}.{name}")
2786
+ self.st["fluent_queries"] += 1
2787
+ return
2788
+ if name in ("save", "create", "update", "delete", "forceDelete", "restore"): # todo.save(on: req.db)
2789
+ ty = (decl.types.get(rv) if decl is not None else None) or self._local_types(decl, sf).get(rv)
2790
+ if ty in self.fluent:
2791
+ self.b.add_edge(owner, f"table:{self.fluent[ty]}", "WRITES_TABLE", sf.rel, line, RESOLVED,
2792
+ via=f"{ty}.{name}")
2793
+ self.st["fluent_writes"] += 1
2794
+ return
2795
+
2796
+ def _fluent_migrations(self, sfiles):
2797
+ """`database.schema("todos")....create()` / `.delete()` / `.update()` in a Migration -> WRITES_TABLE
2798
+ (via migration) from the prepare / revert method."""
2799
+ for sf in sfiles:
2800
+ if b".schema(" not in sf.src:
2801
+ continue
2802
+ for d in self._fd.get(sf.rel, ()):
2803
+ if d.kind != "method" or d.name not in ("prepare", "revert"):
2804
+ continue
2805
+ body = "\n".join(sf.src.decode("utf-8", "replace").split("\n")[d.line - 1:d.end])
2806
+ for m in re.finditer(r'\.schema\s*\(\s*"([^"]+)"\s*\)', body):
2807
+ tail = body[m.end():m.end() + 600]
2808
+ op = re.search(r"\.(create|update|delete)\s*\(\s*\)", tail)
2809
+ tid = self.b.add_node("table", m.group(1), lang="sql", attrs={"via": "fluent"})
2810
+ self.b.add_edge(d.id, tid, "WRITES_TABLE", d.file, d.line, EXACT,
2811
+ via=f"migration {op.group(1) if op else 'schema'}")
2812
+ self.st["fluent_migrations"] += 1
2813
+
2814
+ def _emit_http(self):
2815
+ bases = getattr(self, "bases", {}) or {}
2816
+ base_origins = {split_url(b["value"])[0] for bs in bases.values() for b in bs}
2817
+ for r in self.http:
2818
+ origin, path = split_url(r["url"])
2819
+ okind = "api" if origin is None else ("unknown" if origin.startswith("{") else "other")
2820
+ extra = {}
2821
+ if okind == "unknown" and bases.get(origin[1:-1]):
2822
+ bs = bases[origin[1:-1]]
2823
+ if len(bs) == 1: # one configured value: the backend's origin and path prefix
2824
+ bo, bp = split_url(bs[0]["value"])
2825
+ origin, path, okind = bo, (bp.rstrip("/") + path if bp not in ("", "/") else path), "api"
2826
+ extra = {"base": {"value": bs[0]["value"], "source": bs[0]["source"]}}
2827
+ self.st["http_base_resolved"] += 1
2828
+ else: # one per configuration / environment
2829
+ okind = "env"
2830
+ extra = {"base_candidates": bs[:6]}
2831
+ self.st["http_base_per_environment"] += 1
2832
+ elif okind == "other" and origin in base_origins:
2833
+ okind = "api" # an absolute URL on a configured base's origin
2834
+ self.st["http_base_origin_matched"] += 1
2835
+ key = f"{r['method']} {path}" if okind in ("api", "unknown", "env") else f"{r['method']} {origin}{path}"
2836
+ nid = self.b.add_node("http", key, key, fqn=key, lang="swift",
2837
+ attrs={"method": r["method"], "path": path, "client": r["client"], "origin": origin,
2838
+ "origin_kind": okind, **extra})
2839
+ self.b.add_edge(r["src"], nid, "HTTP_CALLS", r["file"], r["line"], HEURISTIC if okind != "api" else EXACT,
2840
+ client=r["client"], url=r["url"], origin=origin,
2841
+ **({"target": r["target"]} if "target" in r else {}),
2842
+ **({"how": "moya target"} if r.get("declared") else {}))
2843
+ self.st[f"http_{r['client']}"] += 1
2844
+
2845
+ def _page(self, view: Decl, how: str) -> str:
2846
+ key = f"swift:{view.name}"
2847
+ pid = self.b.add_node("page", key, name=view.name, fqn=key, file=view.file, line=view.line, lang="swift",
2848
+ entry_kind="ui_page", attrs={"route": view.name, "via": how, "view": view.fqn})
2849
+ n = self.b.nodes[pid]
2850
+ n.entry_kind = n.entry_kind or "ui_page"
2851
+ m = self.members.get(view.fqn, {})
2852
+ body = m.get("body") or m.get("viewDidLoad") or m.get("loadView")
2853
+ self.b.add_edge(pid, body[0].id if body else view.id, "ROUTES_TO", view.file, view.line, EXACT)
2854
+ return pid
2855
+
2856
+ def _is_controller(self, v: Decl, depth: int = 0) -> bool:
2857
+ if any(s.endswith("ViewController") for s in v.supers):
2858
+ return True
2859
+ return depth < 4 and any(self._is_controller(self.types[s], depth + 1) for s in v.supers if s in self.types)
2860
+
2861
+ def _link_navs(self):
2862
+ # NavigationLink(value: T.x(...)) / navigate(to: .x): the views navigationDestination(for: T.self) shows for
2863
+ # case x (an untyped `.x` only when exactly one destination type has that case)
2864
+ for owner, tname, case, how, file, line in self.value_navs:
2865
+ if tname is None:
2866
+ def declares(t): # the enum declares the case (shown by a `default:` branch)
2867
+ ty = self.types.get(t)
2868
+ return ty is not None and case in self.values.get(ty.fqn, {})
2869
+ hits = [t for t, m in self.dest_map.items() if case in m] or \
2870
+ [t for t, m in self.dest_map.items() if None in m and declares(t)]
2871
+ tname = hits[0] if len(hits) == 1 else None
2872
+ m = self.dest_map.get(tname) if tname else None
2873
+ views = (m.get(case) or m.get(None) or []) if m else []
2874
+ for v, _f, _l in views:
2875
+ self.navs.append((owner, v, how, file, line))
2876
+ if views:
2877
+ self.st["navigations_by_value"] += 1
2878
+ pages = {}
2879
+ for nav in self.navs:
2880
+ owner, tname, how, file, line = nav[:5]
2881
+ page_only = nav[5] if len(nav) > 5 else False
2882
+ v = self.types.get(tname)
2883
+ if v is None or not ("body" in self.members.get(v.fqn, {}) or self._is_controller(v)):
2884
+ continue
2885
+ if v.fqn not in pages:
2886
+ pages[v.fqn] = self._page(v, how)
2887
+ self.st["swiftui_pages"] += 1
2888
+ if not page_only:
2889
+ self.b.add_edge(owner, pages[v.fqn], "NAVIGATES_TO", file, line, EXACT, how=how)
2890
+ self.st["navigations"] += 1