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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- 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
|