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,1961 @@
|
|
|
1
|
+
"""Kotlin language plugin (Android, Kotlin Multiplatform, Ktor, Spring).
|
|
2
|
+
|
|
3
|
+
Heuristic mode: a tree-sitter-kotlin syntax layer. Declarations (packages, classes, interfaces, objects, companion
|
|
4
|
+
objects, top-level / extension functions, methods) become nodes; calls are resolved by name: the enclosing class and
|
|
5
|
+
its supertypes, then a parameter / property type (`api.order()` with `api: OrdersApi`), imports, the same package, and
|
|
6
|
+
finally the name alone (one method: `binding: name`; two to five: a `candidate` edge to each). Every resolved reference is labelled `heuristic`, as for Rust and C / C++ without
|
|
7
|
+
their indexers. Exact mode (exact.py): a scip-java index of the Gradle / Maven build (`--scip`,
|
|
8
|
+
CODEGRAPH_KOTLIN_SCIP_FILE, or an opted-in scip-java run with CODEGRAPH_KOTLIN_SCIP=1) replaces the call edges with
|
|
9
|
+
compiler-resolved ones; without a JDK / scip-java the heuristic layer stays, and coverage says why.
|
|
10
|
+
|
|
11
|
+
Framework facts read from the same syntax tree:
|
|
12
|
+
HTTP clients Retrofit interfaces (@GET("users/{id}") ...; base URL from Retrofit.Builder().baseUrl("...")),
|
|
13
|
+
per-interface / BuildConfig base URLs, Ktor client (client.get("..."), client.get { url("...") },
|
|
14
|
+
client.request { method = HttpMethod.Post }), OkHttp Request.Builder().url("...") -> http:<METHOD> <path>
|
|
15
|
+
servers Ktor routing { route("/a") { get("/{id}") { } } } and type-safe resources get<Res> { } with
|
|
16
|
+
authenticate("x") { } as a guard; SecurityFilterChain requestMatchers(...).hasRole(...) as guards;
|
|
17
|
+
Spring @RestController / @RequestMapping / @GetMapping ... with @PreAuthorize / @Secured /
|
|
18
|
+
@RolesAllowed -> route:<METHOD> <uri>; @Scheduled (scheduled) and @KafkaListener / @RabbitListener /
|
|
19
|
+
@JmsListener / @EventListener (listener) entry points
|
|
20
|
+
Android AndroidManifest.xml activities (ui_page), services / receivers / providers (listener) and deep links
|
|
21
|
+
(<data scheme/host/path>) as pages; Worker / CoroutineWorker / JobService subclasses (queue_job);
|
|
22
|
+
Jetpack Compose Navigation composable("orders/{id}") / composable<OrderRoute>(...) { } / Navigation 3
|
|
23
|
+
entry<Key> { } as pages and
|
|
24
|
+
navController.navigate(...) as NAVIGATES_TO
|
|
25
|
+
Multiplatform KMP source sets (androidMain, iosMain, jvmMain, jsMain ...) as platform conditions (#7 tags), and
|
|
26
|
+
`expect` declarations -> their `actual` implementations (IMPLEMENTED_BY)
|
|
27
|
+
tables Spring Data repositories (JpaRepository<Entity, ID> ...) and Exposed table objects -> READS_TABLE /
|
|
28
|
+
WRITES_TABLE
|
|
29
|
+
Test code (src/test, src/androidTest, *Test source sets, *Test.kt) carries attrs.test; @Test / @ParameterizedTest /
|
|
30
|
+
@RepeatedTest / @TestFactory functions are `test` entries with attrs.framework (junit5, junit4, kotlin-test, testng, kotest)
|
|
31
|
+
from the file's imports; they count as test cases in `cg tests`.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import time
|
|
38
|
+
import xml.etree.ElementTree as ET
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from ...core.syntax_errors import merge, tree_spans
|
|
44
|
+
from .reparse import reparse_members
|
|
45
|
+
from ...core.fsutil import keep_file
|
|
46
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
47
|
+
from ...core.paths import rules as path_rules
|
|
48
|
+
from ...core.plugin import GraphBuilder, LanguagePlugin, Project
|
|
49
|
+
from ..native.ts import TreeSitterMissing
|
|
50
|
+
|
|
51
|
+
EXTS = (".kt", ".kts")
|
|
52
|
+
# an unknown receiver whose method name is declared on more classes than this gets no candidate edges (#83 item 6)
|
|
53
|
+
MAX_CANDIDATES = 5
|
|
54
|
+
VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
55
|
+
RETROFIT = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
|
56
|
+
SPRING_MAP = {"GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT", "DeleteMapping": "DELETE",
|
|
57
|
+
"PatchMapping": "PATCH", "RequestMapping": None}
|
|
58
|
+
# Spring Data repository interfaces (`interface OwnerRepository : JpaRepository<Owner, Int>`) and Exposed tables
|
|
59
|
+
# (`object Users : IntIdTable("users")`): calls on them read / write the table
|
|
60
|
+
REPO_SUPERS = r"(?:Jpa|Crud|ListCrud|PagingAndSorting|ListPagingAndSorting|CoroutineCrud|CoroutineSorting|ReactiveCrud|" \
|
|
61
|
+
r"ReactiveSorting|R2dbc|Mongo|ReactiveMongo|Kotlin)?Repository"
|
|
62
|
+
EXPOSED_TABLES = r"(?:Table|IdTable|IntIdTable|LongIdTable|UUIDTable|UIntIdTable|ULongIdTable|CompositeIdTable)"
|
|
63
|
+
DATA_WRITE = re.compile(r"^(save|delete|remove|insert|update|upsert|batchInsert|batchUpsert|replace|persist|merge|"
|
|
64
|
+
r"flush|truncate)")
|
|
65
|
+
DATA_READ = re.compile(r"^(find|get|read|query|search|stream|count|exists|select|selectAll|all|slice|fetch|load)")
|
|
66
|
+
# Spring Security SecurityFilterChain rules: requestMatchers("/admin/**").hasRole("ADMIN") / Kotlin DSL authorize(...)
|
|
67
|
+
SEC_AUTH = r"(hasRole|hasAnyRole|hasAuthority|hasAnyAuthority|authenticated|fullyAuthenticated|permitAll|denyAll|access)"
|
|
68
|
+
SPRING_GUARDS = {"PreAuthorize", "Secured", "RolesAllowed", "PostAuthorize"}
|
|
69
|
+
LISTENERS = {"KafkaListener", "RabbitListener", "JmsListener", "SqsListener", "EventListener", "StreamListener"}
|
|
70
|
+
WORKER_BASES = {"Worker", "CoroutineWorker", "ListenableWorker", "RxWorker", "JobService", "JobIntentService"}
|
|
71
|
+
LIFECYCLE = re.compile(r"^(on[A-Z]\w*|doWork|startWork|createWork|query|insert|update|delete|getType)$")
|
|
72
|
+
TEST_ANNOTATIONS = {"Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"}
|
|
73
|
+
TEST_FRAMEWORKS = (("org.junit.jupiter.", "junit5"), ("org.junit.", "junit4"), ("kotlin.test.", "kotlin-test"),
|
|
74
|
+
("org.testng.", "testng"), ("io.kotest.", "kotest"))
|
|
75
|
+
TEST_PATH = re.compile(r"(^|/)(src/(test|androidTest|testDebug|testRelease|\w+Test)/|tests?/)|(Test|Tests|Spec)\.kt$")
|
|
76
|
+
# `iosMain`, and the test sets built for one platform (`iosTest`, `androidUnitTest`, `androidInstrumentedTest`, #86)
|
|
77
|
+
SOURCE_SET = re.compile(r"(?:^|/)src/((\w+?)(?:Main|Test|UnitTest|InstrumentedTest))/")
|
|
78
|
+
SET_PLATFORM = {"android": "android", "androidHost": "android", "androidDevice": "android", "ios": "ios",
|
|
79
|
+
"iosArm64": "ios", "iosX64": "ios", "iosSimulatorArm64": "ios",
|
|
80
|
+
"apple": "ios", "macos": "macos", "macosArm64": "macos", "macosX64": "macos", "js": "web",
|
|
81
|
+
"wasmJs": "web", "wasm": "web", "linux": "linux", "linuxX64": "linux", "mingw": "windows",
|
|
82
|
+
"mingwX64": "windows"}
|
|
83
|
+
TEMPLATE = re.compile(r"\$\{([^{}]*)\}|\$([A-Za-z_]\w*)")
|
|
84
|
+
# Ktor path parameters `{id}` / `{id:regex}` normalized to `{id}` for route keys
|
|
85
|
+
_KTOR_PARAM_RE = re.compile(r"\{(\w+)(?::[^{}]*)?\}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parser():
|
|
89
|
+
try:
|
|
90
|
+
from tree_sitter import Language, Parser
|
|
91
|
+
import tree_sitter_kotlin as m
|
|
92
|
+
except ImportError as e: # pragma: no cover - depends on the environment
|
|
93
|
+
raise TreeSitterMissing(f"tree-sitter grammar for kotlin not installed ({e}); "
|
|
94
|
+
f"pip install tree-sitter tree-sitter-kotlin") from e
|
|
95
|
+
return Parser(Language(m.language()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def source_files(root: Path, project=None) -> list[str]:
|
|
99
|
+
rules = path_rules(project, "kotlin")
|
|
100
|
+
out = []
|
|
101
|
+
for dp, dn, fn in os.walk(root):
|
|
102
|
+
rd = os.path.relpath(dp, root).replace(os.sep, "/")
|
|
103
|
+
rd = "" if rd == "." else rd
|
|
104
|
+
dn[:] = sorted(rules.prune(rd, dn, dot=True))
|
|
105
|
+
for f in sorted(fn):
|
|
106
|
+
if f.endswith(EXTS):
|
|
107
|
+
rel = f"{rd}/{f}" if rd else f
|
|
108
|
+
if keep_file(os.path.join(dp, f)) and not rules.excluded(rel):
|
|
109
|
+
out.append(rel)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def template(raw: str) -> str:
|
|
114
|
+
"""Kotlin string literal source -> URL template: "orders/$id" -> orders/{id}, "${base}/x" -> {base}/x."""
|
|
115
|
+
s = raw
|
|
116
|
+
if s.startswith('"""'):
|
|
117
|
+
s = s[3:-3]
|
|
118
|
+
elif s.startswith('"'):
|
|
119
|
+
s = s[1:-1]
|
|
120
|
+
|
|
121
|
+
def rep(m):
|
|
122
|
+
expr = (m.group(1) or m.group(2) or "").strip()
|
|
123
|
+
name = re.sub(r"[^\w.]", "", expr).split(".")[-1] or "?"
|
|
124
|
+
return "{" + name + "}"
|
|
125
|
+
return TEMPLATE.sub(rep, s)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def split_url(t: str) -> tuple[str | None, str]:
|
|
129
|
+
"""(origin, path) of a URL template; origin = scheme://host or a leading {placeholder}; query dropped."""
|
|
130
|
+
t = t.split("?")[0].split("#")[0]
|
|
131
|
+
m = re.match(r"^([a-zA-Z][\w+.-]*://[^/]*)(.*)$", t)
|
|
132
|
+
origin = None
|
|
133
|
+
if m:
|
|
134
|
+
origin, t = m.group(1), m.group(2)
|
|
135
|
+
else:
|
|
136
|
+
m = re.match(r"^(\{[^{}/]*\})(/.*|)$", t)
|
|
137
|
+
if m and not t.startswith("{/"):
|
|
138
|
+
origin, t = m.group(1), m.group(2)
|
|
139
|
+
if not t.startswith("/"):
|
|
140
|
+
t = "/" + t
|
|
141
|
+
return origin, t
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def join_path(base: str, p: str) -> str:
|
|
145
|
+
if not p:
|
|
146
|
+
return base or "/"
|
|
147
|
+
if p.startswith("/") and base:
|
|
148
|
+
return base.rstrip("/") + p
|
|
149
|
+
return (base.rstrip("/") + "/" + p) if base else ("/" + p.lstrip("/"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Decl:
|
|
154
|
+
id: str
|
|
155
|
+
kind: str
|
|
156
|
+
name: str
|
|
157
|
+
fqn: str
|
|
158
|
+
file: str
|
|
159
|
+
line: int
|
|
160
|
+
end: int
|
|
161
|
+
cls: str | None = None # enclosing class fqn
|
|
162
|
+
supers: list = field(default_factory=list)
|
|
163
|
+
annotations: list = field(default_factory=list) # (name, first string arg or None, raw text)
|
|
164
|
+
modifiers: set = field(default_factory=set)
|
|
165
|
+
receiver: str | None = None
|
|
166
|
+
types: dict = field(default_factory=dict) # param / property name -> type name
|
|
167
|
+
test: bool = False
|
|
168
|
+
name_line: int | None = None # line of the name identifier (SCIP definitions sit there)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _module_root(rel: str) -> str:
|
|
172
|
+
"""The Gradle module directory of a source file: the path before its `src/` directory."""
|
|
173
|
+
i = rel.find("/src/")
|
|
174
|
+
return "" if rel.startswith("src/") or i < 0 else rel[:i]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# tree-sitter-kotlin 1.1.0 cannot parse a suspend lambda used as an expression (`val b = suspend { 1 }`,
|
|
179
|
+
# `X to suspend { ... }`): the error swallows the declarations around it (#81). `suspend` becomes seven spaces in
|
|
180
|
+
# front of the `{`, the same length, so byte offsets and the names read from the original source stay as written.
|
|
181
|
+
_SUSPEND_LAMBDA = re.compile(rb"(=|\(|,|\[|\bto|\breturn|->|&&|\|\||\?:)(\s*)suspend(\s*\{)")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# A suspend lambda that starts a statement (`suspend { ... }.runCatchingUpdatingState(state)`) errors the same way;
|
|
185
|
+
# blanked, the lambda would become the trailing lambda of the previous line's call, so `suspend` becomes `;` and six
|
|
186
|
+
# spaces (#104). Not after a line that continues into it (an operator, `=`, `(`, `,`, `.`).
|
|
187
|
+
_SUSPEND_STMT = re.compile(rb"(?m)^([ \t]*)suspend([ \t]*\{)")
|
|
188
|
+
_CONTINUES = (b"=", b"(", b",", b"[", b".", b"->", b"&&", b"||", b"?:", b"+", b"-", b"*", b"/", b"to", b"return")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _suspend_lambdas(src: bytes) -> tuple[bytes, int]:
|
|
192
|
+
if b"suspend" not in src:
|
|
193
|
+
return src, 0
|
|
194
|
+
out, n = _SUSPEND_LAMBDA.subn(lambda m: m.group(1) + m.group(2) + b" " * 7 + m.group(3), src)
|
|
195
|
+
|
|
196
|
+
def stmt(m):
|
|
197
|
+
nonlocal n
|
|
198
|
+
before = out[:m.start()].rstrip()
|
|
199
|
+
prev = before[before.rfind(b"\n") + 1:].split(b"//")[0].rstrip()
|
|
200
|
+
if prev.endswith(_CONTINUES):
|
|
201
|
+
return m.group(0)
|
|
202
|
+
n += 1
|
|
203
|
+
return m.group(1) + b";" + b" " * 6 + m.group(2)
|
|
204
|
+
if b"suspend" in out:
|
|
205
|
+
out = _SUSPEND_STMT.sub(stmt, out)
|
|
206
|
+
return out, n
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# A statement line starting with `get` / `set` right after a `val` / `var` (Ktor routing: `val x = ...` then
|
|
210
|
+
# `get("/path") { }`) parses as that property's accessor and the error swallows the whole function (#104). A getter
|
|
211
|
+
# or setter takes `()` / `(value)`, never a string, a lambda or type arguments, so for `get("...")`, `get { }` and
|
|
212
|
+
# `get<T>` the whitespace character in front of `get` / `set` becomes `;`. That ends the declaration and keeps every
|
|
213
|
+
# byte offset.
|
|
214
|
+
_ROUTE_ACCESSOR = re.compile(rb"([ \t]*)[ \t]((?:get|set)\s*(?:\(\s*(?:\"|\"\"\")|\{|<))")
|
|
215
|
+
_DECL_LINE = re.compile(rb"^\s*(?:(?:private|internal|public|protected|const|lateinit|override|open)\s+)*(?:val|var)\s")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _route_calls(src: bytes) -> tuple[bytes, int]:
|
|
219
|
+
if b"get" not in src and b"set" not in src:
|
|
220
|
+
return src, 0
|
|
221
|
+
lines = src.split(b"\n")
|
|
222
|
+
n, prev = 0, b""
|
|
223
|
+
for i, ln in enumerate(lines):
|
|
224
|
+
m = _ROUTE_ACCESSOR.match(ln)
|
|
225
|
+
# only after a complete declaration line: `val h = X().apply {` + `set("k", v)` is a call inside the lambda
|
|
226
|
+
if m and _DECL_LINE.match(prev) and not prev.rstrip().endswith((b"{", b"(", b"[", b",", b"=", b"->", b".")):
|
|
227
|
+
lines[i] = m.group(1) + b";" + ln[m.end(1) + 1:]
|
|
228
|
+
n += 1
|
|
229
|
+
t = ln.strip()
|
|
230
|
+
if t and not t.startswith((b"//", b"/*", b"*", b"@")):
|
|
231
|
+
prev = ln
|
|
232
|
+
return (b"\n".join(lines), n) if n else (src, 0)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# `dynamic` is the Kotlin/JS type keyword in the grammar, so a call or declaration named `dynamic` (`fun
|
|
236
|
+
# Route.dynamic()`, `dynamic()` in a Ktor module) is a parse error. Its last letter is upper-cased in the parsed copy
|
|
237
|
+
# only; names come from the original bytes (#104).
|
|
238
|
+
_DYNAMIC_CALL = re.compile(rb"(?<![\w$])(dynami)c(?=\s*\()")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _keyword_calls(src: bytes) -> tuple[bytes, int]:
|
|
242
|
+
if b"dynamic" not in src:
|
|
243
|
+
return src, 0
|
|
244
|
+
return _DYNAMIC_CALL.subn(rb"\1C", src)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class KFile:
|
|
248
|
+
def __init__(self, rel: str, src: bytes, tree):
|
|
249
|
+
self.rel, self.src, self.tree = rel, src, tree
|
|
250
|
+
self.package = ""
|
|
251
|
+
self.imports: dict[str, str] = {} # short name -> fqn
|
|
252
|
+
self.star: list[str] = []
|
|
253
|
+
self.test = bool(TEST_PATH.search(rel))
|
|
254
|
+
m = SOURCE_SET.search(rel)
|
|
255
|
+
self.source_set = m.group(2) if m else None
|
|
256
|
+
self.source_set_dir = m.group(1) if m else None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# mutating calls on a stored MutableList / MutableSet / MutableMap / StateFlow field: `items.add(x)` writes `items`
|
|
260
|
+
KOTLIN_MUTATING = frozenset({
|
|
261
|
+
"add", "addAll", "remove", "removeAll", "removeAt", "removeIf", "removeFirst", "removeLast", "retainAll", "clear",
|
|
262
|
+
"put", "putAll", "set", "getOrPut", "compute", "computeIfAbsent", "merge", "sort", "sortBy", "sortWith",
|
|
263
|
+
"sortByDescending", "shuffle", "reverse", "fill", "update"})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class KotlinPlugin(LanguagePlugin):
|
|
267
|
+
name = "kotlin"
|
|
268
|
+
|
|
269
|
+
def detect(self, project: Project) -> bool:
|
|
270
|
+
self._files = source_files(project.root, project)
|
|
271
|
+
return any(f.endswith(".kt") for f in self._files)
|
|
272
|
+
|
|
273
|
+
def t(self, n) -> str:
|
|
274
|
+
return self.cur.src[n.start_byte:n.end_byte].decode("utf-8", "replace")
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ index
|
|
277
|
+
def index(self, project: Project, builder: GraphBuilder, frameworks) -> dict:
|
|
278
|
+
t0 = time.time()
|
|
279
|
+
p = parser()
|
|
280
|
+
self.b = builder
|
|
281
|
+
files = getattr(self, "_files", None)
|
|
282
|
+
if files is None:
|
|
283
|
+
files = source_files(project.root, project)
|
|
284
|
+
self.decls: dict[str, Decl] = {}
|
|
285
|
+
self.test_fw: dict[str, str | None] = {}
|
|
286
|
+
self._fd = None
|
|
287
|
+
self.by_name: dict[str, list[Decl]] = defaultdict(list)
|
|
288
|
+
self.classes: dict[str, Decl] = {}
|
|
289
|
+
self.class_short: dict[str, list[Decl]] = defaultdict(list)
|
|
290
|
+
self.members: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list))
|
|
291
|
+
self.calls: list[tuple] = [] # (owner_id, name, receiver, line, file obj, decl)
|
|
292
|
+
self.call_branch: dict = {} # (owner_id, line, name) -> {branch, branch_line} (#88)
|
|
293
|
+
# properties with a custom accessor, `by lazy` or a delegate are nodes (#89); kept out of by_name / members so
|
|
294
|
+
# a call `x()` never binds to one
|
|
295
|
+
self.props: dict[str, dict] = {} # decl id -> {"property", "accessors"}
|
|
296
|
+
self.props_by_name: dict[str, list[Decl]] = defaultdict(list)
|
|
297
|
+
self.prop_members: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list))
|
|
298
|
+
self.prop_at: dict[tuple, Decl] = {} # (file, start byte) -> decl
|
|
299
|
+
self.stored_names: set[str] = set() # plain stored properties / ctor vals
|
|
300
|
+
# stored properties of a class / enum are `field:<Type>.<name>` nodes with READS_PROP / WRITES_PROP (#88)
|
|
301
|
+
self.fields: dict[str, dict[str, str]] = defaultdict(dict) # class fqn -> name -> field id
|
|
302
|
+
self.field_names: set[str] = set()
|
|
303
|
+
self.copies: list[tuple] = [] # (owner, receiver (type, text), [(arg name, line)], kf, decl): `x.copy(a = 1)`
|
|
304
|
+
self.frefs: list[tuple] = [] # (owner_id, name, receiver (type, text) | None, line, file, decl, write)
|
|
305
|
+
self.reads: list[tuple] = [] # (owner_id, name, receiver node, line, file obj, decl, read | write)
|
|
306
|
+
self.http: list[dict] = []
|
|
307
|
+
self.navs: list[tuple] = []
|
|
308
|
+
self.base_urls: set[str] = set()
|
|
309
|
+
self.api_base: dict[str, set] = defaultdict(set) # Retrofit interface short name -> base URLs it is built with
|
|
310
|
+
self.build_config = self._build_config(project.root, files)
|
|
311
|
+
self.st = defaultdict(int)
|
|
312
|
+
self.values: dict[str, dict[str, str]] = defaultdict(dict) # owner fqn / pkg:<package> -> name -> id
|
|
313
|
+
self.value_fq: dict[str, str] = {} # fqn -> id (imports name them)
|
|
314
|
+
self.const_str: dict[str, tuple] = {} # constant id -> (string literal source, KFile, owner Decl) (#67)
|
|
315
|
+
kfiles, failed = [], []
|
|
316
|
+
errs: dict[str, list] = {}
|
|
317
|
+
for rel in files:
|
|
318
|
+
try:
|
|
319
|
+
src = (project.root / rel).read_bytes()
|
|
320
|
+
except OSError:
|
|
321
|
+
failed.append(rel)
|
|
322
|
+
continue
|
|
323
|
+
psrc, n_susp = _suspend_lambdas(src)
|
|
324
|
+
if n_susp:
|
|
325
|
+
self.st["suspend_lambdas_rewritten"] += n_susp
|
|
326
|
+
self.st["files_with_suspend_lambdas"] += 1
|
|
327
|
+
psrc, n_route = _route_calls(psrc)
|
|
328
|
+
if n_route:
|
|
329
|
+
self.st["accessor_like_calls_rewritten"] += n_route
|
|
330
|
+
psrc, n_kw = _keyword_calls(psrc)
|
|
331
|
+
if n_kw:
|
|
332
|
+
self.st["keyword_named_calls_rewritten"] += n_kw
|
|
333
|
+
tree = p.parse(psrc)
|
|
334
|
+
dropped: list = []
|
|
335
|
+
if tree.root_node.has_error:
|
|
336
|
+
# #104: parse member by member; a member that still errors is blanked, the rest keep their nodes
|
|
337
|
+
nt, dropped, _ = reparse_members(p, psrc, tree)
|
|
338
|
+
if nt is not None:
|
|
339
|
+
tree = nt
|
|
340
|
+
self.st["files_reparsed_by_member"] += 1
|
|
341
|
+
self.st["members_dropped_by_reparse"] += len(dropped)
|
|
342
|
+
kf = KFile(rel, src, tree) # names and text from the original bytes; offsets are unchanged
|
|
343
|
+
if tree.root_node.has_error or dropped:
|
|
344
|
+
self.st["files_with_syntax_errors"] += 1
|
|
345
|
+
errs[rel] = merge(tree_spans(tree.root_node) + [list(x) for x in dropped])
|
|
346
|
+
kfiles.append(kf)
|
|
347
|
+
for kf in kfiles: # pass 1: declarations
|
|
348
|
+
self.cur = kf
|
|
349
|
+
self._header(kf)
|
|
350
|
+
if kf.test:
|
|
351
|
+
self.test_fw[kf.rel] = self._test_framework(kf)
|
|
352
|
+
self._decls(kf.tree.root_node, kf, None, None)
|
|
353
|
+
self._data_models(kfiles)
|
|
354
|
+
for kf in kfiles: # pass 2: references and framework facts
|
|
355
|
+
self.cur = kf
|
|
356
|
+
fid = self._file_node(kf)
|
|
357
|
+
self._refs(kf.tree.root_node, kf, fid, None, {"prefix": "", "guards": [], "routing": False})
|
|
358
|
+
if kf.source_set and kf.source_set in SET_PLATFORM:
|
|
359
|
+
self._mark_platform(kf)
|
|
360
|
+
self._resolve_calls()
|
|
361
|
+
self._hierarchy()
|
|
362
|
+
self._security_rules(kfiles)
|
|
363
|
+
self._emit_http()
|
|
364
|
+
self._link_navs()
|
|
365
|
+
self._manifests(project)
|
|
366
|
+
self.file_report = {"seen": [kf.rel for kf in kfiles] + failed, "parse_failed": failed, "syntax_errors": errs}
|
|
367
|
+
mode = self._exact(project, files)
|
|
368
|
+
st = dict(self.st)
|
|
369
|
+
st.update({"mode": mode, "files": len(kfiles), "kt_files": sum(1 for kf in kfiles if kf.rel.endswith(".kt")),
|
|
370
|
+
"declarations": len(self.decls),
|
|
371
|
+
"seconds": round(time.time() - t0, 2)})
|
|
372
|
+
return st
|
|
373
|
+
|
|
374
|
+
def _exact(self, project: Project, files: list[str]) -> str:
|
|
375
|
+
"""The scip-java layer when an index is available (codegraph/plugins/kotlin/exact.py), else heuristic."""
|
|
376
|
+
from .exact import ExactLayer, find_index, skipped_modules
|
|
377
|
+
t1 = time.time()
|
|
378
|
+
path, info = find_index(project, [f for f in files if f.endswith(EXTS)])
|
|
379
|
+
mode = "heuristic"
|
|
380
|
+
if path is not None:
|
|
381
|
+
sst: dict = {}
|
|
382
|
+
try:
|
|
383
|
+
layer = ExactLayer(self)
|
|
384
|
+
if layer.apply(path, sst):
|
|
385
|
+
mode = "scip"
|
|
386
|
+
sk = skipped_modules(info.get("android_modules"), layer.doc_paths)
|
|
387
|
+
if sk:
|
|
388
|
+
info["skipped_modules"] = sk
|
|
389
|
+
else:
|
|
390
|
+
info["status"] = "the SCIP index has no Kotlin documents"
|
|
391
|
+
except Exception as e: # a corrupt / foreign index must not lose the heuristic graph
|
|
392
|
+
info["status"] = f"SCIP import failed ({type(e).__name__}: {e})"
|
|
393
|
+
for k in ("exact_vs_heuristic", "scip_documents", "scip_files", "scip_defs_matched", "scip_defs_unmatched",
|
|
394
|
+
"scip_references", "scip_refs_external", "java"):
|
|
395
|
+
if k in sst:
|
|
396
|
+
self.st[k] = sst[k]
|
|
397
|
+
if sst.get("scip_warning"):
|
|
398
|
+
info["warning"] = sst["scip_warning"]
|
|
399
|
+
info["seconds"] = round(time.time() - t1, 2)
|
|
400
|
+
self.st["scip"] = info
|
|
401
|
+
return mode
|
|
402
|
+
|
|
403
|
+
def _file_node(self, kf: KFile) -> str:
|
|
404
|
+
return self.b.add_node("file", f"kotlin:{kf.rel}", name=kf.rel, file=kf.rel, line=1, lang="kotlin",
|
|
405
|
+
module=kf.package or None, attrs={"test": True} if kf.test else {})
|
|
406
|
+
|
|
407
|
+
def _header(self, kf: KFile):
|
|
408
|
+
for c in kf.tree.root_node.children:
|
|
409
|
+
if c.type == "package_header":
|
|
410
|
+
q = next((x for x in c.children if x.type == "qualified_identifier"), None)
|
|
411
|
+
kf.package = self.t(q) if q else ""
|
|
412
|
+
elif c.type == "import":
|
|
413
|
+
q = next((x for x in c.children if x.type == "qualified_identifier"), None)
|
|
414
|
+
if q is None:
|
|
415
|
+
continue
|
|
416
|
+
fq = self.t(q)
|
|
417
|
+
alias = next((self.t(x) for x in c.children if x.type == "identifier"), None)
|
|
418
|
+
if self.t(c).rstrip().endswith("*"):
|
|
419
|
+
kf.star.append(fq)
|
|
420
|
+
else:
|
|
421
|
+
kf.imports[alias or fq.rsplit(".", 1)[-1]] = fq
|
|
422
|
+
elif c.type == "imports" or c.type == "import_list":
|
|
423
|
+
for x in c.children:
|
|
424
|
+
if x.type == "import":
|
|
425
|
+
q = next((y for y in x.children if y.type == "qualified_identifier"), None)
|
|
426
|
+
if q is not None:
|
|
427
|
+
fq = self.t(q)
|
|
428
|
+
kf.imports[fq.rsplit(".", 1)[-1]] = fq
|
|
429
|
+
|
|
430
|
+
def _test_framework(self, kf: KFile) -> str | None:
|
|
431
|
+
"""The test framework a test file's `@Test` comes from: the explicit `Test` import, else a star import, else
|
|
432
|
+
the first framework import in the file (JUnit 5 > JUnit 4 > kotlin.test > TestNG)."""
|
|
433
|
+
fq = kf.imports.get("Test") or kf.imports.get("ParameterizedTest") or ""
|
|
434
|
+
cands = [fq] if fq else []
|
|
435
|
+
cands += [f + ".*" for f in kf.star]
|
|
436
|
+
cands += list(kf.imports.values())
|
|
437
|
+
for c in cands:
|
|
438
|
+
for prefix, fw in TEST_FRAMEWORKS:
|
|
439
|
+
if c.startswith(prefix):
|
|
440
|
+
return fw
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------ pass 1
|
|
444
|
+
def _annotations(self, n) -> tuple[list, set]:
|
|
445
|
+
anns, mods = [], set()
|
|
446
|
+
# tree-sitter-kotlin quirk: annotations right after the package header (no imports) parse as a sibling
|
|
447
|
+
# annotated_expression in front of the declaration
|
|
448
|
+
prev = n.prev_named_sibling
|
|
449
|
+
if prev is not None and prev.type == "annotated_expression" and n.start_point[0] - prev.end_point[0] <= 1:
|
|
450
|
+
for m in re.finditer(r'@([\w.]+)(\s*\((?:[^()]|\([^()]*\))*\))?', self.t(prev)):
|
|
451
|
+
s = re.search(r'"((?:[^"\\]|\\.)*)"', m.group(2) or "")
|
|
452
|
+
anns.append((m.group(1).split(".")[-1], s.group(1) if s else None, m.group(0)))
|
|
453
|
+
for c in n.children:
|
|
454
|
+
if c.type != "modifiers":
|
|
455
|
+
continue
|
|
456
|
+
for m in c.children:
|
|
457
|
+
if m.type == "annotation":
|
|
458
|
+
txt = self.t(m)
|
|
459
|
+
mm = re.match(r"@(?:[\w]+:)?([\w.]+)", txt)
|
|
460
|
+
nm = mm.group(1).split(".")[-1] if mm else txt
|
|
461
|
+
s = re.search(r'"((?:[^"\\]|\\.)*)"', txt)
|
|
462
|
+
anns.append((nm, s.group(1) if s else None, txt))
|
|
463
|
+
else:
|
|
464
|
+
mods.update(self.t(m).split())
|
|
465
|
+
return anns, mods
|
|
466
|
+
|
|
467
|
+
def _name_node(self, n):
|
|
468
|
+
x = n.child_by_field_name("name")
|
|
469
|
+
if x is None:
|
|
470
|
+
x = next((c for c in n.children if c.type in ("identifier", "type_identifier", "simple_identifier")), None)
|
|
471
|
+
return x
|
|
472
|
+
|
|
473
|
+
def _name(self, n) -> str | None:
|
|
474
|
+
x = self._name_node(n)
|
|
475
|
+
return self.t(x) if x is not None else None
|
|
476
|
+
|
|
477
|
+
def _name_line(self, n) -> int:
|
|
478
|
+
x = self._name_node(n)
|
|
479
|
+
return (x if x is not None else n).start_point[0] + 1
|
|
480
|
+
|
|
481
|
+
def _decls(self, n, kf: KFile, cls: Decl | None, fn: Decl | None):
|
|
482
|
+
for c in n.children:
|
|
483
|
+
ty = c.type
|
|
484
|
+
if ty in ("class_declaration", "object_declaration", "companion_object"):
|
|
485
|
+
nm = self._name(c) or ("Companion" if ty == "companion_object" else None)
|
|
486
|
+
if not nm:
|
|
487
|
+
self._decls(c, kf, cls, fn)
|
|
488
|
+
continue
|
|
489
|
+
fq = f"{cls.fqn}.{nm}" if cls else (f"{kf.package}.{nm}" if kf.package else nm)
|
|
490
|
+
anns, mods = self._annotations(c)
|
|
491
|
+
head = self.t(c).split("{", 1)[0]
|
|
492
|
+
kk = "interface" if re.search(r"\binterface\b", head) else ("object" if ty != "class_declaration" else
|
|
493
|
+
("enum" if "enum" in mods else "class"))
|
|
494
|
+
supers = []
|
|
495
|
+
for d in c.children:
|
|
496
|
+
if d.type == "delegation_specifiers":
|
|
497
|
+
for ut in d.children:
|
|
498
|
+
m = re.match(r"\s*([\w.]+)", self.t(ut))
|
|
499
|
+
if m:
|
|
500
|
+
supers.append(m.group(1).split(".")[-1])
|
|
501
|
+
key = fq + self._variant(kf, mods)
|
|
502
|
+
types = {}
|
|
503
|
+
for d in c.children:
|
|
504
|
+
if d.type == "primary_constructor":
|
|
505
|
+
types.update(self._params(d))
|
|
506
|
+
dc = Decl(f"class:{key}", "class", nm, fq, kf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
|
|
507
|
+
cls.fqn if cls else None, supers, anns, mods, types=types, test=kf.test,
|
|
508
|
+
name_line=self._name_line(c))
|
|
509
|
+
self._add_decl(dc, kk)
|
|
510
|
+
body = next((d for d in c.children if d.type in ("class_body", "enum_class_body")), None)
|
|
511
|
+
if kk in ("class", "enum"):
|
|
512
|
+
for d in c.children:
|
|
513
|
+
if d.type == "primary_constructor":
|
|
514
|
+
for cp in d.children:
|
|
515
|
+
for q in (cp.children if cp.type == "class_parameters" else []):
|
|
516
|
+
kw = next((self.t(x) for x in q.children if x.type in ("val", "var")), None)
|
|
517
|
+
ids = [x for x in q.children if x.type == "identifier"]
|
|
518
|
+
if q.type == "class_parameter" and kw and ids:
|
|
519
|
+
self._field(dc, self.t(ids[0]), kw, kf, q)
|
|
520
|
+
if body is not None:
|
|
521
|
+
for d in body.children:
|
|
522
|
+
if d.type == "property_declaration":
|
|
523
|
+
dc.types.update(self._prop_type(d))
|
|
524
|
+
if not self._prop_kind(d):
|
|
525
|
+
nm_ = self._name(next((x for x in d.children if x.type == "variable_declaration"), d))
|
|
526
|
+
if nm_:
|
|
527
|
+
self.stored_names.add(nm_)
|
|
528
|
+
kw = next((self.t(x).split()[-1] for x in d.children if x.type == "binding_pattern_kind"
|
|
529
|
+
or self.t(x) in ("val", "var")), "val")
|
|
530
|
+
if kk in ("class", "enum") and not self._is_constant(d, dc):
|
|
531
|
+
self._field(dc, nm_, kw, kf, d)
|
|
532
|
+
self._decls(body, kf, dc, None)
|
|
533
|
+
self.stored_names.update(types)
|
|
534
|
+
elif ty == "function_declaration":
|
|
535
|
+
nm = self._name(c)
|
|
536
|
+
if not nm:
|
|
537
|
+
continue
|
|
538
|
+
anns, mods = self._annotations(c)
|
|
539
|
+
recv = None
|
|
540
|
+
for d in c.children:
|
|
541
|
+
if d.type == "identifier":
|
|
542
|
+
break
|
|
543
|
+
if d.type in ("user_type", "nullable_type"):
|
|
544
|
+
recv = self.t(d).split("<")[0].rstrip("?")
|
|
545
|
+
owner = cls.fqn if cls else (kf.package or "")
|
|
546
|
+
fq = f"{owner}.{nm}" if owner else nm
|
|
547
|
+
kind = "method" if cls else "function"
|
|
548
|
+
params = next((d for d in c.children if d.type == "function_value_parameters"), None)
|
|
549
|
+
dc = Decl(f"{kind}:{fq}{self._variant(kf, mods)}", kind, nm, fq, kf.rel, c.start_point[0] + 1,
|
|
550
|
+
c.end_point[0] + 1, cls.fqn if cls else None, [], anns, mods, recv,
|
|
551
|
+
self._params(params) if params is not None else {}, kf.test, self._name_line(c))
|
|
552
|
+
self._local_types(c, dc.types)
|
|
553
|
+
self._add_decl(dc, kind)
|
|
554
|
+
elif ty == "enum_entry" and cls is not None:
|
|
555
|
+
nm = self._name(c)
|
|
556
|
+
if nm:
|
|
557
|
+
self._value(cls, "enum_case", nm, kf, c)
|
|
558
|
+
body = next((d for d in c.children if d.type == "class_body"), None)
|
|
559
|
+
if body is not None: # `GREEN { override fun label() = "g" }`: the enum's members
|
|
560
|
+
self._decls(body, kf, cls, fn)
|
|
561
|
+
elif ty == "property_declaration" and fn is None and self._prop_kind(c):
|
|
562
|
+
self._prop_decl(c, kf, cls)
|
|
563
|
+
elif ty == "property_declaration" and fn is None and self._is_constant(c, cls):
|
|
564
|
+
nm = self._name(next((d for d in c.children if d.type == "variable_declaration"), c))
|
|
565
|
+
if nm:
|
|
566
|
+
nid = self._value(cls, "constant", nm, kf, c)
|
|
567
|
+
eq = next((i for i, x in enumerate(c.children) if x.type == "="), None)
|
|
568
|
+
init = c.children[eq + 1] if eq is not None and eq + 1 < len(c.children) else None
|
|
569
|
+
if init is not None and init.type == "string_literal":
|
|
570
|
+
self.const_str[nid] = (self.t(init), kf, cls)
|
|
571
|
+
elif ty in ("property_declaration",) and cls is None:
|
|
572
|
+
pass
|
|
573
|
+
else:
|
|
574
|
+
self._decls(c, kf, cls, fn)
|
|
575
|
+
|
|
576
|
+
def _field(self, cls: Decl, nm: str, kw: str, kf: KFile, n):
|
|
577
|
+
"""A stored property of a class (`var items = ...`, a constructor `val owner`): `field:<Type>.<name>` (#88)."""
|
|
578
|
+
if nm in self.fields[cls.fqn]:
|
|
579
|
+
return
|
|
580
|
+
a = {"property": "stored", "binding": "var" if kw.endswith("var") else "val"}
|
|
581
|
+
if kf.test:
|
|
582
|
+
a["test"] = True
|
|
583
|
+
fid = self.b.add_node("field", f"{cls.fqn}.{nm}", name=nm, fqn=f"{cls.fqn}.{nm}", file=kf.rel,
|
|
584
|
+
line=n.start_point[0] + 1, end_line=n.end_point[0] + 1,
|
|
585
|
+
module=getattr(self.b.nodes.get(cls.id), "module", None), lang="kotlin", attrs=a)
|
|
586
|
+
self.b.add_edge(cls.id, fid, "CONTAINS", kf.rel, n.start_point[0] + 1, EXACT)
|
|
587
|
+
self.fields[cls.fqn][nm] = fid
|
|
588
|
+
self.field_names.add(nm)
|
|
589
|
+
self.st["stored_property_nodes"] += 1
|
|
590
|
+
|
|
591
|
+
def _prop_kind(self, c) -> tuple | None:
|
|
592
|
+
"""(property kind, accessors) of a property that runs code when read or written (#89): a custom `get()` /
|
|
593
|
+
`set(value)` (custom), `by lazy { }` (lazy) or another delegate (delegated); None for a stored property."""
|
|
594
|
+
acc = []
|
|
595
|
+
for x in c.children:
|
|
596
|
+
if x.type == "getter":
|
|
597
|
+
acc.append("get")
|
|
598
|
+
elif x.type == "setter":
|
|
599
|
+
acc.append("set")
|
|
600
|
+
elif x.type == "property_delegate":
|
|
601
|
+
acc.append("lazy" if re.match(r"by\s+lazy\b", self.t(x)) else "delegate")
|
|
602
|
+
if not acc:
|
|
603
|
+
return None
|
|
604
|
+
kind = "custom" if ("get" in acc or "set" in acc) else ("lazy" if "lazy" in acc else "delegated")
|
|
605
|
+
return kind, acc
|
|
606
|
+
|
|
607
|
+
def _prop_decl(self, c, kf: KFile, cls: Decl | None):
|
|
608
|
+
"""`method:<Type>.<name>` (in a class), `function:<package>.<name>` (top level; an extension property keeps
|
|
609
|
+
its receiver, as extension functions do), kotlin_kind property."""
|
|
610
|
+
v = next((x for x in c.children if x.type == "variable_declaration"), None)
|
|
611
|
+
nm = self._name(v) if v is not None else None
|
|
612
|
+
if not nm:
|
|
613
|
+
return
|
|
614
|
+
kind, acc = self._prop_kind(c)
|
|
615
|
+
recv = None
|
|
616
|
+
for d in c.children:
|
|
617
|
+
if d.type == "variable_declaration":
|
|
618
|
+
break
|
|
619
|
+
if d.type in ("user_type", "nullable_type"):
|
|
620
|
+
recv = self.t(d).split("<")[0].rstrip("?")
|
|
621
|
+
anns, mods = self._annotations(c)
|
|
622
|
+
owner = cls.fqn if cls else (kf.package or "")
|
|
623
|
+
fq = f"{owner}.{nm}" if owner else nm
|
|
624
|
+
k = "method" if cls else "function"
|
|
625
|
+
types = {}
|
|
626
|
+
ty = next((x for x in v.children if x.type in ("user_type", "nullable_type")), None)
|
|
627
|
+
dc = Decl(f"{k}:{fq}{self._variant(kf, mods)}", k, nm, fq, kf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
|
|
628
|
+
cls.fqn if cls else None, [], anns, mods, recv, types, kf.test, self._name_line(v))
|
|
629
|
+
if dc.id in self.decls:
|
|
630
|
+
return
|
|
631
|
+
self.props[dc.id] = {"property": kind, "accessors": acc,
|
|
632
|
+
"type": self.t(ty).split("<")[0].rstrip("?").split(".")[-1] if ty is not None else None}
|
|
633
|
+
self._add_decl(dc, "property", prop=True)
|
|
634
|
+
self.b.nodes[dc.id].attrs.update(property=kind, accessors=acc)
|
|
635
|
+
self.props_by_name[nm].append(dc)
|
|
636
|
+
if dc.cls:
|
|
637
|
+
self.prop_members[dc.cls][nm].append(dc)
|
|
638
|
+
self.prop_at[(kf.rel, c.start_byte)] = dc
|
|
639
|
+
self.st["property_nodes"] += 1
|
|
640
|
+
|
|
641
|
+
def _is_constant(self, c, cls: Decl | None) -> bool:
|
|
642
|
+
"""`const val` anywhere; a `val` / `var` of an `object` or `companion object`; a file-level `val` (#84). No
|
|
643
|
+
custom getter (that is computed each read)."""
|
|
644
|
+
if any(x.type == "getter" for x in c.children) or re.search(r"\bget\s*\(", self.t(c).split("=", 1)[0]):
|
|
645
|
+
return False
|
|
646
|
+
_anns, mods = self._annotations(c)
|
|
647
|
+
if "const" in mods:
|
|
648
|
+
return True
|
|
649
|
+
if cls is None:
|
|
650
|
+
vb = next((x for x in c.children if x.type in ("val", "var") or self.t(x) in ("val", "var")), None)
|
|
651
|
+
return vb is not None and self.t(vb) == "val" and any(x.type == "=" for x in c.children)
|
|
652
|
+
return self.b.nodes.get(cls.id) is not None and \
|
|
653
|
+
self.b.nodes[cls.id].attrs.get("kotlin_kind") == "object"
|
|
654
|
+
|
|
655
|
+
def _value(self, cls: Decl | None, kind: str, nm: str, kf: KFile, n):
|
|
656
|
+
"""An enum entry (`enum_case:<Enum>.<NAME>`) or a constant (`constant:<Type>.<NAME>`; a companion object's
|
|
657
|
+
is its class's, as code names it: `K.A`; `constant:<package>.<NAME>` at file level): the node USES_VALUE
|
|
658
|
+
edges point at (#84). Not a call target."""
|
|
659
|
+
owner = cls
|
|
660
|
+
if cls is not None and cls.name == "Companion" and cls.cls:
|
|
661
|
+
owner = self.classes.get(cls.cls) or cls
|
|
662
|
+
ofq = owner.fqn if owner is not None else (kf.package or "")
|
|
663
|
+
fq = f"{ofq}.{nm}" if ofq else nm
|
|
664
|
+
key = f"{fq}@{cls.id.split('@', 1)[1]}" if cls is not None and "@" in cls.id else fq
|
|
665
|
+
line = n.start_point[0] + 1
|
|
666
|
+
nid = self.b.add_node(kind, key, name=nm, fqn=fq, file=kf.rel, line=line, end_line=n.end_point[0] + 1,
|
|
667
|
+
module=(fq.rsplit(".", 1)[0] if "." in fq else None), lang="kotlin",
|
|
668
|
+
attrs={"test": True} if kf.test else {})
|
|
669
|
+
if cls is not None:
|
|
670
|
+
self.b.add_edge(cls.id, nid, "CONTAINS", kf.rel, line, EXACT)
|
|
671
|
+
self.values[owner.fqn if owner is not None else f"pkg:{kf.package}"].setdefault(nm, nid)
|
|
672
|
+
self.value_fq.setdefault(fq, nid)
|
|
673
|
+
return nid
|
|
674
|
+
|
|
675
|
+
def _const_id(self, expr: str, kf: KFile, cls: Decl | None) -> str | None:
|
|
676
|
+
"""The constant `NAME` / `Obj.NAME` / `a.b.Obj.NAME` names, seen from a class (its own and its companion's
|
|
677
|
+
constants) in file kf: imports, then the file's package."""
|
|
678
|
+
expr = expr.strip()
|
|
679
|
+
if not re.fullmatch(r"[A-Za-z_][\w.]*", expr):
|
|
680
|
+
return None
|
|
681
|
+
if "." in expr:
|
|
682
|
+
tgt, nm = expr.rsplit(".", 1)
|
|
683
|
+
t = self.classes.get(tgt) or (self._class_of(tgt, kf) if "." not in tgt and tgt[:1].isupper() else None)
|
|
684
|
+
if t is not None:
|
|
685
|
+
if t.name == "Companion" and t.cls:
|
|
686
|
+
t = self.classes.get(t.cls) or t
|
|
687
|
+
return self.values.get(t.fqn, {}).get(nm)
|
|
688
|
+
return self.value_fq.get(expr)
|
|
689
|
+
c = cls
|
|
690
|
+
while c is not None: # own constants, then those of the enclosing classes
|
|
691
|
+
if c.name == "Companion" and c.cls:
|
|
692
|
+
c = self.classes.get(c.cls) or c
|
|
693
|
+
vid = self.values.get(c.fqn, {}).get(expr)
|
|
694
|
+
if vid is not None:
|
|
695
|
+
return vid
|
|
696
|
+
c = self.classes.get(c.cls) if c.cls else None
|
|
697
|
+
fq = kf.imports.get(expr)
|
|
698
|
+
if fq:
|
|
699
|
+
return self.value_fq.get(fq)
|
|
700
|
+
return self.values.get(f"pkg:{kf.package}", {}).get(expr)
|
|
701
|
+
|
|
702
|
+
def _expand(self, raw: str, kf: KFile, cls: Decl | None, depth: int = 0) -> str:
|
|
703
|
+
"""A string literal's source with the `$NAME` / `${Obj.NAME}` of string constants put in (#67:
|
|
704
|
+
`const val TASKS_ROUTE = "$TASKS_SCREEN?$ARG={$ARG}"`); other templates stay for template()."""
|
|
705
|
+
def rep(m):
|
|
706
|
+
expr = m.group(1) if m.group(1) is not None else m.group(2)
|
|
707
|
+
vid = self._const_id(expr, kf, cls) if depth < 4 else None
|
|
708
|
+
got = self.const_str.get(vid) if vid else None
|
|
709
|
+
if got is None:
|
|
710
|
+
return m.group(0)
|
|
711
|
+
body = self._expand(got[0], got[1], got[2], depth + 1)
|
|
712
|
+
return body[3:-3] if body.startswith('"""') else body[1:-1]
|
|
713
|
+
return TEMPLATE.sub(rep, raw)
|
|
714
|
+
|
|
715
|
+
def _route_arg(self, args, kf: KFile, decl: Decl | None, named: str | None = None) -> str | None:
|
|
716
|
+
"""The route string of `composable(...)` / `navigate(...)`: a string literal (constants in it expanded) or a
|
|
717
|
+
string constant (`Destinations.TASKS_ROUTE`), positional or `route = ...`."""
|
|
718
|
+
if args is None:
|
|
719
|
+
return None
|
|
720
|
+
x = None
|
|
721
|
+
for a in args.children:
|
|
722
|
+
if a.type != "value_argument":
|
|
723
|
+
continue
|
|
724
|
+
kids = [k for k in a.children if k.is_named]
|
|
725
|
+
if a.children and any(k.type == "=" for k in a.children):
|
|
726
|
+
if named and kids and self.t(kids[0]) == named and len(kids) > 1:
|
|
727
|
+
x = kids[-1]
|
|
728
|
+
break
|
|
729
|
+
continue
|
|
730
|
+
if x is None and kids:
|
|
731
|
+
x = kids[0]
|
|
732
|
+
if not named:
|
|
733
|
+
break
|
|
734
|
+
if x is None:
|
|
735
|
+
return None
|
|
736
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.kind != "class" and decl.cls else \
|
|
737
|
+
(decl if decl is not None and decl.kind == "class" else None)
|
|
738
|
+
if x.type == "string_literal":
|
|
739
|
+
return template(self._expand(self.t(x), kf, cls))
|
|
740
|
+
if x.type in ("identifier", "simple_identifier", "navigation_expression"):
|
|
741
|
+
vid = self._const_id(self.t(x), kf, cls)
|
|
742
|
+
got = self.const_str.get(vid) if vid else None
|
|
743
|
+
if got is not None:
|
|
744
|
+
self.st["routes_from_constants"] += 1
|
|
745
|
+
return template(self._expand(got[0], got[1], got[2]))
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
def _variant(self, kf: KFile, mods: set) -> str:
|
|
749
|
+
return f"@{kf.source_set}" if "actual" in mods and kf.source_set else ""
|
|
750
|
+
|
|
751
|
+
def _params(self, n) -> dict:
|
|
752
|
+
out = {}
|
|
753
|
+
for p in (n.children if n is not None else []):
|
|
754
|
+
if p.type in ("parameter", "class_parameter", "class_parameters", "function_value_parameter"):
|
|
755
|
+
if p.type == "class_parameters":
|
|
756
|
+
out.update(self._params(p))
|
|
757
|
+
continue
|
|
758
|
+
ids = [c for c in p.children if c.type == "identifier"]
|
|
759
|
+
ty = next((c for c in p.children if c.type in ("user_type", "nullable_type")), None)
|
|
760
|
+
if ids and ty is not None:
|
|
761
|
+
out[self.t(ids[0])] = self.t(ty).split("<")[0].rstrip("?").split(".")[-1]
|
|
762
|
+
return out
|
|
763
|
+
|
|
764
|
+
def _local_types(self, fn, types: dict):
|
|
765
|
+
"""`val repo = OrderRepository(..)` / `val api: OrdersApi = ..` in a function body (not in nested classes or
|
|
766
|
+
functions): the local's type, so `repo.save()` binds through it (#96). Parameters win; flow-insensitive."""
|
|
767
|
+
body = next((c for c in fn.children if c.type == "function_body"), None)
|
|
768
|
+
stack = list(body.children) if body is not None else []
|
|
769
|
+
while stack:
|
|
770
|
+
n = stack.pop()
|
|
771
|
+
if n.type in ("class_declaration", "object_declaration", "function_declaration"):
|
|
772
|
+
continue
|
|
773
|
+
if n.type == "property_declaration":
|
|
774
|
+
for k, v in self._prop_type(n).items():
|
|
775
|
+
types.setdefault(k, v)
|
|
776
|
+
stack.extend(n.children)
|
|
777
|
+
|
|
778
|
+
def _prop_type(self, d) -> dict:
|
|
779
|
+
v = next((c for c in d.children if c.type == "variable_declaration"), None)
|
|
780
|
+
if v is None:
|
|
781
|
+
return {}
|
|
782
|
+
ids = [c for c in v.children if c.type == "identifier"]
|
|
783
|
+
ty = next((c for c in v.children if c.type in ("user_type", "nullable_type")), None)
|
|
784
|
+
if ids and ty is not None:
|
|
785
|
+
return {self.t(ids[0]): self.t(ty).split("<")[0].rstrip("?").split(".")[-1]}
|
|
786
|
+
# val api = Retrofit...create(OrdersApi::class.java) / val repo = OrderRepository(...)
|
|
787
|
+
txt = self.t(d)
|
|
788
|
+
m = re.search(r"create\(\s*(\w+)::class", txt) or re.match(r"[^=]*=(?!=)\s*([A-Z]\w*)\s*\(", txt) \
|
|
789
|
+
or re.search(r"\bby\s+lazy\s*(?:\([^)]*\))?\s*\{\s*([A-Z]\w*)\s*\(", txt) \
|
|
790
|
+
or re.match(r"[^=]*=(?!=)\s*(?:mockk|spyk|mock|spy)\s*<\s*([A-Z]\w*)", txt) # = mockk<Repo> { .. }
|
|
791
|
+
if ids and m:
|
|
792
|
+
return {self.t(ids[0]): m.group(1)}
|
|
793
|
+
return {}
|
|
794
|
+
|
|
795
|
+
def _add_decl(self, d: Decl, display_kind: str, prop: bool = False):
|
|
796
|
+
attrs = {"kotlin_kind": display_kind}
|
|
797
|
+
if d.test:
|
|
798
|
+
attrs["test"] = True
|
|
799
|
+
for a in ("expect", "actual", "suspend", "override", "abstract", "data", "sealed"):
|
|
800
|
+
if a in d.modifiers:
|
|
801
|
+
attrs[a] = True
|
|
802
|
+
if d.receiver:
|
|
803
|
+
attrs["receiver"] = d.receiver
|
|
804
|
+
if d.annotations:
|
|
805
|
+
attrs["annotations"] = [a[0] for a in d.annotations][:12]
|
|
806
|
+
pkg = d.fqn.rsplit(".", 1)[0] if "." in d.fqn else ""
|
|
807
|
+
self.b.add_node(d.kind, d.id.split(":", 1)[1], name=d.name, fqn=d.fqn, file=d.file, line=d.line, end_line=d.end,
|
|
808
|
+
module=pkg or None, lang="kotlin", attrs=attrs)
|
|
809
|
+
if d.test and d.kind in ("function", "method") and any(a[0] in TEST_ANNOTATIONS for a in d.annotations):
|
|
810
|
+
n = self.b.nodes[d.id]
|
|
811
|
+
n.entry_kind = "test"
|
|
812
|
+
fw = self.test_fw.get(d.file)
|
|
813
|
+
if fw:
|
|
814
|
+
n.attrs["framework"] = fw
|
|
815
|
+
if any(a[0] == "ParameterizedTest" for a in d.annotations):
|
|
816
|
+
n.attrs["parameterized"] = True
|
|
817
|
+
self.decls[d.id] = d
|
|
818
|
+
if prop:
|
|
819
|
+
if d.cls:
|
|
820
|
+
self.b.add_edge(f"class:{d.cls}", d.id, "CONTAINS", d.file, d.line, EXACT)
|
|
821
|
+
return
|
|
822
|
+
self.by_name[d.name].append(d)
|
|
823
|
+
if d.kind == "class":
|
|
824
|
+
if "expect" in d.modifiers:
|
|
825
|
+
self.classes[d.fqn] = d # common code names the `expect` class, not the actual of whichever source
|
|
826
|
+
else: # set happened to be read first (#86)
|
|
827
|
+
self.classes.setdefault(d.fqn, d)
|
|
828
|
+
self.class_short[d.name].append(d)
|
|
829
|
+
if d.cls:
|
|
830
|
+
self.members[d.cls][d.name].append(d)
|
|
831
|
+
self.b.add_edge(f"class:{d.cls}", d.id, "CONTAINS", d.file, d.line, EXACT)
|
|
832
|
+
self._decl_facts(d)
|
|
833
|
+
|
|
834
|
+
def _decl_facts(self, d: Decl):
|
|
835
|
+
names = {a[0] for a in d.annotations}
|
|
836
|
+
if d.kind != "class":
|
|
837
|
+
if "Scheduled" in names:
|
|
838
|
+
self.b.nodes[d.id].entry_kind = "scheduled"
|
|
839
|
+
self.st["scheduled"] += 1
|
|
840
|
+
elif names & LISTENERS:
|
|
841
|
+
self.b.nodes[d.id].entry_kind = "listener"
|
|
842
|
+
self.st["listeners"] += 1
|
|
843
|
+
|
|
844
|
+
# ------------------------------------------------------------------ pass 2
|
|
845
|
+
def _owner_at(self, kf: KFile, line: int) -> Decl | None:
|
|
846
|
+
best = None
|
|
847
|
+
for d in self._file_decls(kf):
|
|
848
|
+
if d.kind != "class" and d.line <= line <= d.end and (best is None or d.end - d.line < best.end - best.line):
|
|
849
|
+
best = d
|
|
850
|
+
return best
|
|
851
|
+
|
|
852
|
+
def _file_decls(self, kf: KFile):
|
|
853
|
+
fd = getattr(self, "_fd", None)
|
|
854
|
+
if fd is None:
|
|
855
|
+
fd = self._fd = defaultdict(list)
|
|
856
|
+
for d in self.decls.values():
|
|
857
|
+
fd[d.file].append(d)
|
|
858
|
+
return fd.get(kf.rel, ())
|
|
859
|
+
|
|
860
|
+
def _decl_at(self, kf: KFile, n, kinds) -> Decl | None:
|
|
861
|
+
line = n.start_point[0] + 1
|
|
862
|
+
for d in self._file_decls(kf):
|
|
863
|
+
if d.line == line and d.kind in kinds and d.name == self._name(n):
|
|
864
|
+
return d
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
def _refs(self, n, kf: KFile, owner: str, decl: Decl | None, ctx: dict):
|
|
868
|
+
for c in n.children:
|
|
869
|
+
ty = c.type
|
|
870
|
+
if self.values and ty in ("navigation_expression", "identifier"):
|
|
871
|
+
self._value_ref(c, kf, owner, decl)
|
|
872
|
+
if (self.props_by_name or self.field_names) and ty in ("navigation_expression", "identifier"):
|
|
873
|
+
self._prop_ref(c, kf, owner, decl)
|
|
874
|
+
if ty == "property_declaration" and (kf.rel, c.start_byte) in self.prop_at:
|
|
875
|
+
d = self.prop_at[(kf.rel, c.start_byte)]
|
|
876
|
+
self._refs(c, kf, d.id, d, {"prefix": "", "guards": [], "routing": False})
|
|
877
|
+
continue
|
|
878
|
+
if ty == "function_declaration":
|
|
879
|
+
d = self._decl_at(kf, c, ("function", "method"))
|
|
880
|
+
if d is not None:
|
|
881
|
+
self._fn_facts(d)
|
|
882
|
+
rctx = {"prefix": "", "guards": [], "routing": (d.receiver or "").split(".")[-1] in ("Route", "Routing")}
|
|
883
|
+
self._refs(c, kf, d.id, d, rctx)
|
|
884
|
+
continue
|
|
885
|
+
elif ty in ("class_declaration", "object_declaration", "companion_object"):
|
|
886
|
+
d = self._decl_at(kf, c, ("class",)) if ty != "companion_object" else None
|
|
887
|
+
self._refs(c, kf, d.id if d else owner, d or decl, ctx)
|
|
888
|
+
continue
|
|
889
|
+
elif ty == "call_expression":
|
|
890
|
+
self._copy_call(c, kf, owner, decl)
|
|
891
|
+
if self._call(c, kf, owner, decl, ctx):
|
|
892
|
+
continue
|
|
893
|
+
elif ty == "binary_expression":
|
|
894
|
+
m = re.match(r"(composable|entry|dialog|(?:get|post|put|delete|patch|head|options))\s*<\s*([\w.]+)\s*>",
|
|
895
|
+
self.t(c))
|
|
896
|
+
lam = next((x for x in c.children if x.type in ("lambda_literal", "annotated_lambda")), None)
|
|
897
|
+
if m and lam is not None and self._typed_block(m.group(1), m.group(2), c, lam, kf, owner, decl, ctx):
|
|
898
|
+
continue
|
|
899
|
+
self._refs(c, kf, owner, decl, ctx)
|
|
900
|
+
|
|
901
|
+
def _call(self, c, kf: KFile, owner: str, decl: Decl | None, ctx: dict) -> bool:
|
|
902
|
+
"""Record one call; returns True when it walked the children itself (route / page lambdas)."""
|
|
903
|
+
line = c.start_point[0] + 1
|
|
904
|
+
callee = c.children[0] if c.children else None
|
|
905
|
+
lam = next((x for x in c.children if x.type == "annotated_lambda"), None)
|
|
906
|
+
args = next((x for x in c.children if x.type == "value_arguments"), None)
|
|
907
|
+
targs = None
|
|
908
|
+
if callee is not None and callee.type == "call_expression" and lam is not None: # f("x") { ... }
|
|
909
|
+
args = next((x for x in callee.children if x.type == "value_arguments"), None)
|
|
910
|
+
targs = next((x for x in callee.children if x.type == "type_arguments"), None)
|
|
911
|
+
callee = callee.children[0] if callee.children else None
|
|
912
|
+
if callee is None:
|
|
913
|
+
return False
|
|
914
|
+
recv, name = None, None
|
|
915
|
+
if callee.type == "identifier":
|
|
916
|
+
name = self.t(callee)
|
|
917
|
+
elif callee.type == "navigation_expression":
|
|
918
|
+
ids = callee.children
|
|
919
|
+
name = self.t(ids[-1]) if ids and ids[-1].type == "identifier" else None
|
|
920
|
+
recv = ids[0] if ids else None
|
|
921
|
+
if not name:
|
|
922
|
+
return False
|
|
923
|
+
sarg = self._first_string(args)
|
|
924
|
+
rtext = self.t(recv) if recv is not None else None
|
|
925
|
+
# ---- Ktor server routing
|
|
926
|
+
if lam is not None and recv is None:
|
|
927
|
+
if name == "routing":
|
|
928
|
+
self._refs(lam, kf, owner, decl, {"prefix": ctx["prefix"], "guards": ctx["guards"], "routing": True})
|
|
929
|
+
return True
|
|
930
|
+
if ctx.get("routing") and name == "route" and sarg is not None:
|
|
931
|
+
self._refs(lam, kf, owner, decl, {**ctx, "prefix": join_path(ctx["prefix"], template(sarg))})
|
|
932
|
+
return True
|
|
933
|
+
if ctx.get("routing") and name == "authenticate":
|
|
934
|
+
g = f"authenticate({template(sarg) if sarg else ''})"
|
|
935
|
+
self._refs(lam, kf, owner, decl, {**ctx, "guards": ctx["guards"] + [g]})
|
|
936
|
+
return True
|
|
937
|
+
if ctx.get("routing") and name in VERBS:
|
|
938
|
+
uri = join_path(ctx["prefix"], template(sarg) if sarg is not None else "")
|
|
939
|
+
hid = self._ktor_route(kf, name.upper(), uri, c, owner, ctx)
|
|
940
|
+
self._refs(lam, kf, hid, decl, {**ctx, "routing": False})
|
|
941
|
+
return True
|
|
942
|
+
route = self._route_arg(args, kf, decl, "route") if name in ("composable", "dialog") and targs is None else None
|
|
943
|
+
if route is not None:
|
|
944
|
+
pid = self._page(kf, route, c)
|
|
945
|
+
self._refs(lam, kf, pid, decl, ctx)
|
|
946
|
+
return True
|
|
947
|
+
# composable<Route>(deepLinks = ...) { }, Navigation 3 entry<Key>(metadata = ...) { }, Ktor get<Res>(...) { }
|
|
948
|
+
if targs is not None and self._typed_block(name, self.t(targs).strip("<> "), c, lam, kf, owner, decl, ctx):
|
|
949
|
+
return True
|
|
950
|
+
# ---- HTTP clients
|
|
951
|
+
if (name in VERBS or name == "request") and recv is not None and re.search(r"(?i)client|http", rtext or ""):
|
|
952
|
+
first = self._first_arg(args)
|
|
953
|
+
if first is not None and first.type == "string_literal":
|
|
954
|
+
self.http.append({"src": owner, "method": "GET" if name == "request" else name.upper(),
|
|
955
|
+
"url": template(self.t(first)), "client": "ktor", "file": kf.rel, "line": line})
|
|
956
|
+
elif first is None and lam is not None:
|
|
957
|
+
# builder block: client.get { url("...") } / client.request { method = HttpMethod.Post; url { path("...") } }
|
|
958
|
+
body = self.t(lam)
|
|
959
|
+
mu = re.search(r'\burl\s*\(\s*("(?:[^"\\]|\\.)*")', body) or \
|
|
960
|
+
re.search(r'\b(?:path|encodedPath\s*=|appendPathSegments)\s*\(?\s*("(?:[^"\\]|\\.)*")', body)
|
|
961
|
+
mm = re.search(r"\bmethod\s*=\s*HttpMethod\.(\w+)", body)
|
|
962
|
+
if mu:
|
|
963
|
+
verb = mm.group(1).upper() if mm else ("GET" if name == "request" else name.upper())
|
|
964
|
+
self.http.append({"src": owner, "method": verb, "url": template(mu.group(1)), "client": "ktor",
|
|
965
|
+
"file": kf.rel, "line": line})
|
|
966
|
+
if name == "url" and rtext and "Request.Builder" in rtext and sarg is not None:
|
|
967
|
+
whole = c
|
|
968
|
+
while whole.parent is not None and whole.parent.type in ("navigation_expression", "call_expression"):
|
|
969
|
+
whole = whole.parent
|
|
970
|
+
tail = self.t(whole)[len(self.t(c)):]
|
|
971
|
+
m = re.search(r"\.(post|put|delete|patch|head)\s*\(", tail)
|
|
972
|
+
self.http.append({"src": owner, "method": m.group(1).upper() if m else "GET", "url": template(self._raw_first(args)),
|
|
973
|
+
"client": "okhttp", "file": kf.rel, "line": line})
|
|
974
|
+
if name == "baseUrl" and args is not None:
|
|
975
|
+
first = self._first_arg(args)
|
|
976
|
+
raw = sarg
|
|
977
|
+
if raw is None and first is not None:
|
|
978
|
+
bc = re.fullmatch(r"(?:[\w.]+\.)?BuildConfig\.(\w+)", self.t(first).strip())
|
|
979
|
+
vals = self.build_config.get(bc.group(1)) if bc else None
|
|
980
|
+
raw = f'"{vals[0]}"' if vals and len(vals) == 1 else None
|
|
981
|
+
if raw is not None:
|
|
982
|
+
base = template(raw)
|
|
983
|
+
self.base_urls.add(base)
|
|
984
|
+
whole = c # Retrofit.Builder().baseUrl(X)...build().create(Api::class.java)
|
|
985
|
+
while whole.parent is not None and whole.parent.type in ("navigation_expression", "call_expression",
|
|
986
|
+
"value_argument", "value_arguments"):
|
|
987
|
+
whole = whole.parent
|
|
988
|
+
for api in re.findall(r"create\s*\(\s*(\w+)::class", self.t(whole)) + \
|
|
989
|
+
re.findall(r"create<\s*(\w+)\s*>", self.t(whole)):
|
|
990
|
+
self.api_base[api].add(base)
|
|
991
|
+
if name == "navigate" and args is not None:
|
|
992
|
+
first = self._first_arg(args)
|
|
993
|
+
route = self._route_arg(args, kf, decl, "route") if first is not None else None
|
|
994
|
+
if first is not None:
|
|
995
|
+
if route is not None:
|
|
996
|
+
self.navs.append((owner, route, None, kf.rel, line))
|
|
997
|
+
else:
|
|
998
|
+
m = re.match(r"([A-Z]\w*)", self.t(first))
|
|
999
|
+
if m:
|
|
1000
|
+
self.navs.append((owner, None, m.group(1), kf.rel, line))
|
|
1001
|
+
self.calls.append((owner, name, rtext, line, kf, decl, self._accessor(c) if owner in self.props else None))
|
|
1002
|
+
if name[:1].isupper(): # a constructor or a composable: its branch (#88)
|
|
1003
|
+
br = self._branch(c)
|
|
1004
|
+
if br:
|
|
1005
|
+
self.call_branch[(owner, line, name)] = br
|
|
1006
|
+
return False
|
|
1007
|
+
|
|
1008
|
+
def _branch(self, c) -> dict:
|
|
1009
|
+
"""The innermost `if` / `else` / `when` entry around a construction or a composable call inside its function
|
|
1010
|
+
(#88): attrs.branch (`if (loading)`, `else of if (loading)`, `when Tab.Home`, `else of when (tab)`) and
|
|
1011
|
+
attrs.branch_line, so a screen that swaps content under a condition shows it on the edge."""
|
|
1012
|
+
x, prev = c.parent, c
|
|
1013
|
+
while x is not None and x.type not in ("function_declaration", "class_body", "source_file",
|
|
1014
|
+
"secondary_constructor", "anonymous_initializer", "getter", "setter"):
|
|
1015
|
+
if x.type == "when_entry":
|
|
1016
|
+
ch = x.children
|
|
1017
|
+
if ch and ch[0].type == "else":
|
|
1018
|
+
w = x.parent
|
|
1019
|
+
subj = next((k for k in w.children if k.type == "when_subject"), None) if w is not None else None
|
|
1020
|
+
lab = "else of when" + (f" {self.t(subj)}" if subj is not None else "")
|
|
1021
|
+
else:
|
|
1022
|
+
conds = []
|
|
1023
|
+
for k in ch:
|
|
1024
|
+
if k.type == "->":
|
|
1025
|
+
break
|
|
1026
|
+
if k.type != ",":
|
|
1027
|
+
conds.append(self.t(k))
|
|
1028
|
+
lab = "when " + ", ".join(conds)
|
|
1029
|
+
if prev is not ch[0]:
|
|
1030
|
+
return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
|
|
1031
|
+
if x.type == "if_expression":
|
|
1032
|
+
ch = x.children
|
|
1033
|
+
lp = next((i for i, k in enumerate(ch) if k.type == "("), None)
|
|
1034
|
+
rp = next((i for i, k in enumerate(ch) if k.type == ")"), None)
|
|
1035
|
+
cond = " ".join(self.t(k) for k in ch[lp + 1:rp]) if lp is not None and rp is not None else ""
|
|
1036
|
+
els = next((k for k in ch if k.type == "else"), None)
|
|
1037
|
+
inside = rp is not None and prev.start_byte > ch[rp].start_byte
|
|
1038
|
+
if inside:
|
|
1039
|
+
lab = ("else of " if els is not None and prev.start_byte > els.start_byte else "") + f"if ({cond})"
|
|
1040
|
+
return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
|
|
1041
|
+
prev, x = x, x.parent
|
|
1042
|
+
return {}
|
|
1043
|
+
|
|
1044
|
+
@staticmethod
|
|
1045
|
+
def _accessor(c) -> str | None:
|
|
1046
|
+
"""get / set / lazy / delegate: the part of a property node a call sits in (#89)."""
|
|
1047
|
+
a = c.parent
|
|
1048
|
+
while a is not None and a.type not in ("property_declaration", "function_declaration", "class_body"):
|
|
1049
|
+
if a.type == "getter":
|
|
1050
|
+
return "get"
|
|
1051
|
+
if a.type == "setter":
|
|
1052
|
+
return "set"
|
|
1053
|
+
if a.type == "property_delegate":
|
|
1054
|
+
return "lazy" if a.children and re.match(r"by\s+lazy\b", a.text.decode("utf-8", "replace")) else "delegate"
|
|
1055
|
+
a = a.parent
|
|
1056
|
+
return "init" if a is not None and a.type == "property_declaration" else None
|
|
1057
|
+
|
|
1058
|
+
def _prop_ref(self, c, kf: KFile, owner: str, decl: Decl | None):
|
|
1059
|
+
"""A read (or write) of a property node: `cart.label`, `summary`, `3.asPrice`, `cart.items = x` (#89)."""
|
|
1060
|
+
p, pre = c.parent, False
|
|
1061
|
+
if c.type == "navigation_expression":
|
|
1062
|
+
kids = [x for x in c.children if x.type != "."]
|
|
1063
|
+
if len(kids) != 2 or kids[1].type != "identifier":
|
|
1064
|
+
return
|
|
1065
|
+
nm, recv = self.t(kids[1]), kids[0]
|
|
1066
|
+
if (recv.type == "unary_expression" and len(recv.children) == 2
|
|
1067
|
+
and recv.children[0].type in ("++", "--")):
|
|
1068
|
+
pre, recv = True, recv.children[1] # `--c.n` parses as `(--c).n`
|
|
1069
|
+
else:
|
|
1070
|
+
if p is None or p.type in ("variable_declaration", "parameter", "class_parameter", "function_value_parameter",
|
|
1071
|
+
"import", "qualified_identifier", "package_header", "user_type", "type_identifier",
|
|
1072
|
+
"enum_entry", "function_declaration", "class_declaration", "object_declaration",
|
|
1073
|
+
"lambda_parameters", "annotation", "constructor_invocation", "label",
|
|
1074
|
+
"callable_reference"):
|
|
1075
|
+
return
|
|
1076
|
+
if p.type == "navigation_expression" and p.children and p.children[0] != c:
|
|
1077
|
+
return
|
|
1078
|
+
if p.type == "value_argument" and c.next_sibling is not None and c.next_sibling.type == "=":
|
|
1079
|
+
return
|
|
1080
|
+
nm, recv = self.t(c), None
|
|
1081
|
+
if nm not in self.props_by_name and nm not in self.field_names:
|
|
1082
|
+
return
|
|
1083
|
+
if p is not None and p.type == "call_expression" and p.children and p.children[0] == c:
|
|
1084
|
+
return # `x.label()`: a call, not a property read
|
|
1085
|
+
mode = "read"
|
|
1086
|
+
if p is not None and p.type == "assignment" and p.children and p.children[0] == c:
|
|
1087
|
+
# `x = v` writes; `x += v` / `x -= v` run the getter and the setter (#105)
|
|
1088
|
+
mode = "write" if len(p.children) > 1 and p.children[1].type == "=" else "read_write"
|
|
1089
|
+
elif pre or p is not None and p.type == "unary_expression" and any(x.type in ("++", "--") for x in p.children):
|
|
1090
|
+
mode = "read_write" # `count++` / `--cart.count`
|
|
1091
|
+
rv = (recv.type, self.t(recv)) if recv is not None else None # text now: self.t reads the current file
|
|
1092
|
+
if nm in self.props_by_name:
|
|
1093
|
+
self.reads.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, mode))
|
|
1094
|
+
if nm in self.field_names:
|
|
1095
|
+
if mode == "read_write": # a stored property read and written in place (#105)
|
|
1096
|
+
self.frefs.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, False))
|
|
1097
|
+
w = mode != "read"
|
|
1098
|
+
if not w and p is not None and p.type == "navigation_expression" and p.children and p.children[0] == c:
|
|
1099
|
+
ids = [x for x in p.children if x.type == "identifier"]
|
|
1100
|
+
gp = p.parent
|
|
1101
|
+
if (len(ids) >= 1 and ids[-1] is not c and self.t(ids[-1]) in KOTLIN_MUTATING and gp is not None
|
|
1102
|
+
and gp.type == "call_expression" and gp.children and gp.children[0] == p):
|
|
1103
|
+
w = "mutating" # `items.add(x)` on a MutableList / Set / Map field
|
|
1104
|
+
elif (len(ids) >= 1 and ids[-1] is not c and self.t(ids[-1]) == "value" and gp is not None
|
|
1105
|
+
and gp.type == "assignment" and gp.children and gp.children[0] == p):
|
|
1106
|
+
w = "value" # `_state.value = x` on a MutableStateFlow / LiveData field
|
|
1107
|
+
self.frefs.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, w))
|
|
1108
|
+
|
|
1109
|
+
def _first_arg(self, args):
|
|
1110
|
+
if args is None:
|
|
1111
|
+
return None
|
|
1112
|
+
for a in args.children:
|
|
1113
|
+
if a.type == "value_argument":
|
|
1114
|
+
for x in a.children:
|
|
1115
|
+
if x.is_named:
|
|
1116
|
+
return x
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
def _first_string(self, args) -> str | None:
|
|
1120
|
+
x = self._first_arg(args)
|
|
1121
|
+
return self.t(x) if x is not None and x.type == "string_literal" else None
|
|
1122
|
+
|
|
1123
|
+
def _raw_first(self, args) -> str:
|
|
1124
|
+
return self._first_string(args) or '""'
|
|
1125
|
+
|
|
1126
|
+
# ------------------------------------------------------------------ framework facts
|
|
1127
|
+
def _fn_facts(self, d: Decl):
|
|
1128
|
+
anns = {a[0]: a for a in d.annotations}
|
|
1129
|
+
cls = self.classes.get(d.cls) if d.cls else None
|
|
1130
|
+
# Retrofit interface methods
|
|
1131
|
+
for nm, a in anns.items():
|
|
1132
|
+
if nm in RETROFIT and a[1] is not None:
|
|
1133
|
+
self.http.append({"src": d.id, "method": nm, "url": template('"' + a[1] + '"'), "client": "retrofit",
|
|
1134
|
+
"file": d.file, "line": d.line, "relative": True,
|
|
1135
|
+
"iface": (d.cls or "").split(".")[-1]})
|
|
1136
|
+
elif nm == "HTTP":
|
|
1137
|
+
m = re.search(r'method\s*=\s*"(\w+)"', a[2])
|
|
1138
|
+
pm = re.search(r'path\s*=\s*"([^"]*)"', a[2])
|
|
1139
|
+
if m and pm:
|
|
1140
|
+
self.http.append({"src": d.id, "method": m.group(1).upper(), "url": template('"' + pm.group(1) + '"'),
|
|
1141
|
+
"client": "retrofit", "file": d.file, "line": d.line, "relative": True,
|
|
1142
|
+
"iface": (d.cls or "").split(".")[-1]})
|
|
1143
|
+
# Spring MVC / WebFlux annotations
|
|
1144
|
+
if cls is not None and any(nm in SPRING_MAP for nm in anns):
|
|
1145
|
+
canns = {a[0]: a for a in cls.annotations}
|
|
1146
|
+
if not ({"RestController", "Controller"} & set(canns)) and "RequestMapping" not in canns:
|
|
1147
|
+
return
|
|
1148
|
+
prefix = self._mapping_path(canns.get("RequestMapping"))
|
|
1149
|
+
guards = [self._guard(a) for nm2, a in canns.items() if nm2 in SPRING_GUARDS]
|
|
1150
|
+
guards += [self._guard(a) for nm2, a in anns.items() if nm2 in SPRING_GUARDS]
|
|
1151
|
+
for nm, a in anns.items():
|
|
1152
|
+
if nm not in SPRING_MAP:
|
|
1153
|
+
continue
|
|
1154
|
+
verbs = [SPRING_MAP[nm]] if SPRING_MAP[nm] else (re.findall(r"RequestMethod\.(\w+)", a[2]) or ["ANY"])
|
|
1155
|
+
paths = self._mapping_paths(a)
|
|
1156
|
+
for v in verbs:
|
|
1157
|
+
for p in paths:
|
|
1158
|
+
uri = join_path(prefix or "", p) if (prefix or p) else "/"
|
|
1159
|
+
self._route(v, uri, d.id, d.file, d.line, "spring", guards, EXACT)
|
|
1160
|
+
|
|
1161
|
+
def _mapping_paths(self, a) -> list[str]:
|
|
1162
|
+
if a is None:
|
|
1163
|
+
return [""]
|
|
1164
|
+
inner = a[2][a[2].find("(") + 1:a[2].rfind(")")] if "(" in a[2] else ""
|
|
1165
|
+
m = re.search(r"(?:value|path)\s*=\s*(\[[^\]]*\]|arrayOf\([^)]*\)|\"[^\"]*\")", inner)
|
|
1166
|
+
part = m.group(1) if m else (inner if inner and not re.match(r"\s*\w+\s*=", inner) else "")
|
|
1167
|
+
ps = re.findall(r'"((?:[^"\\]|\\.)*)"', part)
|
|
1168
|
+
return ps or [""]
|
|
1169
|
+
|
|
1170
|
+
def _mapping_path(self, a) -> str:
|
|
1171
|
+
return self._mapping_paths(a)[0] if a is not None else ""
|
|
1172
|
+
|
|
1173
|
+
def _guard(self, a) -> str:
|
|
1174
|
+
return f"{a[0]}({a[1]})" if a[1] is not None else a[0]
|
|
1175
|
+
|
|
1176
|
+
def _route(self, method: str, uri: str, handler: str | None, file: str, line: int, fw: str, guards: list,
|
|
1177
|
+
conf: str) -> str:
|
|
1178
|
+
uri = re.sub(r"\{(\w+)(?::[^{}]*)?\}", r"{\1}", uri) # Spring {id:\d+} -> {id}
|
|
1179
|
+
if not uri.startswith("/"):
|
|
1180
|
+
uri = "/" + uri
|
|
1181
|
+
key = f"{method} {uri}"
|
|
1182
|
+
attrs = {"uri": uri, "method": method, "framework": fw}
|
|
1183
|
+
if guards:
|
|
1184
|
+
attrs["middleware"] = list(dict.fromkeys(guards))
|
|
1185
|
+
rid = self.b.add_node("route", key, name=key, file=file, line=line, lang="kotlin", entry_kind="http_route",
|
|
1186
|
+
attrs=attrs)
|
|
1187
|
+
if self.b.nodes[rid].entry_kind is None:
|
|
1188
|
+
self.b.nodes[rid].entry_kind = "http_route"
|
|
1189
|
+
if handler:
|
|
1190
|
+
self.b.add_edge(rid, handler, "ROUTES_TO", file, line, conf)
|
|
1191
|
+
self.b.nodes[rid].attrs.setdefault("handler", self.b.nodes[handler].name if handler in self.b.nodes else handler)
|
|
1192
|
+
self.st["routes"] += 1
|
|
1193
|
+
self.st[f"routes_{fw}"] += 1
|
|
1194
|
+
return rid
|
|
1195
|
+
|
|
1196
|
+
def _ktor_route(self, kf: KFile, method: str, uri: str, c, owner: str, ctx: dict) -> str:
|
|
1197
|
+
line = c.start_point[0] + 1
|
|
1198
|
+
hk = f"{kf.package + '.' if kf.package else ''}<{method} {uri}>@{kf.rel}:{line}"
|
|
1199
|
+
hid = self.b.add_node("function", hk, name=f"{method} {uri} handler", fqn=hk, file=kf.rel, line=line,
|
|
1200
|
+
end_line=c.end_point[0] + 1, module=kf.package or None, lang="kotlin",
|
|
1201
|
+
attrs={"lambda": True, "kotlin_kind": "route handler", **({"test": True} if kf.test else {})})
|
|
1202
|
+
self._route(method, uri, hid, kf.rel, line, "ktor", ctx["guards"], EXACT)
|
|
1203
|
+
norm_uri = _KTOR_PARAM_RE.sub(r"{\1}", uri)
|
|
1204
|
+
self.b.add_edge(owner, f"route:{method} {norm_uri}", "REFERENCES_FN", kf.rel,
|
|
1205
|
+
line, EXACT, how="router registration")
|
|
1206
|
+
return hid
|
|
1207
|
+
|
|
1208
|
+
def _page(self, kf: KFile, route: str, c, typed: bool = False) -> str:
|
|
1209
|
+
line = c.start_point[0] + 1
|
|
1210
|
+
key = f"kotlin:{route}"
|
|
1211
|
+
pid = self.b.add_node("page", key, name=route, fqn=key, file=kf.rel, line=line, end_line=c.end_point[0] + 1,
|
|
1212
|
+
lang="kotlin", entry_kind="ui_page",
|
|
1213
|
+
attrs={"route": route, "via": "compose-navigation" + (" (typed)" if typed else "")})
|
|
1214
|
+
if self.b.nodes[pid].entry_kind is None:
|
|
1215
|
+
self.b.nodes[pid].entry_kind = "ui_page"
|
|
1216
|
+
self.st["compose_pages"] += 1
|
|
1217
|
+
return pid
|
|
1218
|
+
|
|
1219
|
+
def _mark_platform(self, kf: KFile):
|
|
1220
|
+
from ...platforms import Cond, _plat_atom, mark
|
|
1221
|
+
plat = SET_PLATFORM[kf.source_set]
|
|
1222
|
+
lines = kf.src.count(b"\n") + 1
|
|
1223
|
+
mark(self.b, kf.rel, 1, lines, Cond("tree", _plat_atom(plat), f"source set {kf.source_set_dir}"))
|
|
1224
|
+
self.st["platform_source_set_files"] += 1
|
|
1225
|
+
|
|
1226
|
+
# ------------------------------------------------------------------ resolution
|
|
1227
|
+
def _value_ref(self, c, kf: KFile, owner: str, decl: Decl | None):
|
|
1228
|
+
"""USES_VALUE to an enum entry or constant named for certain (#84): `Color.RED`, `K.A` (a companion's),
|
|
1229
|
+
`a.b.MAX`, or a bare `RED` / `MAX` that is imported, of the enclosing class or of the file's package, and not
|
|
1230
|
+
a local or parameter of that name."""
|
|
1231
|
+
line = c.start_point[0] + 1
|
|
1232
|
+
if c.type == "navigation_expression":
|
|
1233
|
+
kids = [x for x in c.children if x.type != "."]
|
|
1234
|
+
if len(kids) != 2 or kids[1].type != "identifier":
|
|
1235
|
+
return
|
|
1236
|
+
tgt, nm = self.t(kids[0]), self.t(kids[1])
|
|
1237
|
+
vid = None
|
|
1238
|
+
if re.fullmatch(r"[A-Za-z_][\w.]*", tgt):
|
|
1239
|
+
t = self.classes.get(tgt) or (self._class_of(tgt, kf) if "." not in tgt and tgt[:1].isupper() else None)
|
|
1240
|
+
if t is not None:
|
|
1241
|
+
if t.name == "Companion" and t.cls:
|
|
1242
|
+
t = self.classes.get(t.cls) or t
|
|
1243
|
+
vid = self.values.get(t.fqn, {}).get(nm)
|
|
1244
|
+
elif "." in tgt:
|
|
1245
|
+
vid = self.value_fq.get(f"{tgt}.{nm}")
|
|
1246
|
+
if vid is not None and vid != owner:
|
|
1247
|
+
self.b.add_edge(owner, vid, "USES_VALUE", kf.rel, line, EXACT, how="member")
|
|
1248
|
+
self.st["value_refs"] += 1
|
|
1249
|
+
return
|
|
1250
|
+
p = c.parent
|
|
1251
|
+
if p is None or p.type in ("variable_declaration", "parameter", "class_parameter", "function_value_parameter",
|
|
1252
|
+
"import", "qualified_identifier", "package_header", "user_type", "type_identifier",
|
|
1253
|
+
"enum_entry", "function_declaration", "class_declaration", "object_declaration",
|
|
1254
|
+
"lambda_parameters", "annotation", "constructor_invocation", "label"):
|
|
1255
|
+
return
|
|
1256
|
+
if p.type == "navigation_expression" and p.children and p.children[0] != c:
|
|
1257
|
+
return
|
|
1258
|
+
if p.type == "call_expression" and p.children and p.children[0] == c:
|
|
1259
|
+
return
|
|
1260
|
+
if p.type == "value_argument" and c.next_sibling is not None and c.next_sibling.type == "=":
|
|
1261
|
+
return
|
|
1262
|
+
nm = self.t(c)
|
|
1263
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.kind != "class" and decl.cls else \
|
|
1264
|
+
(decl if decl is not None and decl.kind == "class" else None)
|
|
1265
|
+
if cls is not None and cls.name == "Companion" and cls.cls:
|
|
1266
|
+
cls = self.classes.get(cls.cls) or cls
|
|
1267
|
+
vid = self.values.get(cls.fqn, {}).get(nm) if cls is not None else None
|
|
1268
|
+
how = "own type"
|
|
1269
|
+
if vid is None:
|
|
1270
|
+
fq = kf.imports.get(nm)
|
|
1271
|
+
vid, how = (self.value_fq.get(fq), "import") if fq else (None, how)
|
|
1272
|
+
if vid is None and not kf.imports.get(nm):
|
|
1273
|
+
vid, how = self.values.get(f"pkg:{kf.package}", {}).get(nm), "package"
|
|
1274
|
+
vn = self.b.nodes.get(vid) if vid else None
|
|
1275
|
+
if vn is not None and _module_root(vn.file) != _module_root(kf.rel):
|
|
1276
|
+
vid = None # the same package in another Gradle module is not visible
|
|
1277
|
+
if vid is None or vid == owner or self._shadowed(decl, nm, kf, line):
|
|
1278
|
+
return
|
|
1279
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", nm) and self._in_lambda(c):
|
|
1280
|
+
return # `testApplication { client }`: a receiver's member may be meant
|
|
1281
|
+
self.b.add_edge(owner, vid, "USES_VALUE", kf.rel, line, EXACT, how=how)
|
|
1282
|
+
self.st["value_refs"] += 1
|
|
1283
|
+
|
|
1284
|
+
@staticmethod
|
|
1285
|
+
def _in_lambda(c) -> bool:
|
|
1286
|
+
a = c.parent
|
|
1287
|
+
while a is not None and a.type not in ("function_declaration", "class_body", "source_file"):
|
|
1288
|
+
if a.type in ("lambda_literal", "annotated_lambda"):
|
|
1289
|
+
return True
|
|
1290
|
+
a = a.parent
|
|
1291
|
+
return False
|
|
1292
|
+
|
|
1293
|
+
def _shadowed(self, decl: Decl | None, nm: str, kf: KFile, line: int) -> bool:
|
|
1294
|
+
"""A parameter, local `val` / `var`, lambda parameter or destructured name `nm` in the function around `line`."""
|
|
1295
|
+
if decl is None or decl.kind == "class":
|
|
1296
|
+
return False
|
|
1297
|
+
if nm in decl.types:
|
|
1298
|
+
return True
|
|
1299
|
+
lines = kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end]
|
|
1300
|
+
return bool(re.search(rf"(?:\b(?:val|var)\s+(?:\([^)]*)?|[(,]\s*|\{{\s*(?:[\w\s,]*,\s*)?){re.escape(nm)}\b\s*(?:[:=),]|->|in\b)",
|
|
1301
|
+
"\n".join(lines)))
|
|
1302
|
+
|
|
1303
|
+
def _shadowed_local(self, decl: Decl | None, nm: str, kf: KFile) -> bool:
|
|
1304
|
+
"""`_shadowed` for stored-property refs (#88), without its argument-shaped false positives: `{ x = …` (an
|
|
1305
|
+
assignment opening a body) and `f(x)` (an argument) do not declare `x`. A parameter / local / lambda parameter
|
|
1306
|
+
/ destructured name / `for (x in …)` does."""
|
|
1307
|
+
if decl is None or decl.kind == "class":
|
|
1308
|
+
return False
|
|
1309
|
+
if nm in decl.types:
|
|
1310
|
+
return True
|
|
1311
|
+
body = "\n".join(kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end])
|
|
1312
|
+
n = re.escape(nm)
|
|
1313
|
+
return bool(re.search(rf"\b(?:val|var)\s+(?:\([^)]*?)?\b{n}\b\s*(?:[:=),]|by\b)" # val x = / val (a, x) = / var x by
|
|
1314
|
+
rf"|\bfor\s*\(\s*(?:\([^)]*?)?\b{n}\b[^)]*?\bin\b" # for (x in / for ((a, x) in
|
|
1315
|
+
rf"|[(,]\s*{n}\s*:" # (x: T a parameter
|
|
1316
|
+
rf"|\{{\s*(?:\(?\s*[\w\s,:<>?]*,\s*)?{n}\b\s*(?:,[\w\s,:<>?]*)?\)?\s*->", body)) # { a, x -> / { (a, x) ->
|
|
1317
|
+
|
|
1318
|
+
def _class_of(self, short: str, kf: KFile) -> Decl | None:
|
|
1319
|
+
fq = kf.imports.get(short)
|
|
1320
|
+
if fq and fq in self.classes:
|
|
1321
|
+
return self.classes[fq]
|
|
1322
|
+
if kf.package and f"{kf.package}.{short}" in self.classes:
|
|
1323
|
+
return self.classes[f"{kf.package}.{short}"]
|
|
1324
|
+
cands = self.class_short.get(short) or []
|
|
1325
|
+
return cands[0] if len(cands) == 1 else None
|
|
1326
|
+
|
|
1327
|
+
def _member(self, cls: Decl, name: str, depth: int = 0) -> list[Decl]:
|
|
1328
|
+
hit = self.members.get(cls.fqn, {}).get(name)
|
|
1329
|
+
if hit:
|
|
1330
|
+
return hit
|
|
1331
|
+
if depth > 6:
|
|
1332
|
+
return []
|
|
1333
|
+
for s in cls.supers:
|
|
1334
|
+
sc = self.classes.get(s) or (self.class_short.get(s) or [None])[0]
|
|
1335
|
+
if sc is not None and sc is not cls:
|
|
1336
|
+
r = self._member(sc, name, depth + 1)
|
|
1337
|
+
if r:
|
|
1338
|
+
return r
|
|
1339
|
+
return []
|
|
1340
|
+
|
|
1341
|
+
def _resolve_calls(self):
|
|
1342
|
+
for owner, name, recv, line, kf, decl, acc in self.calls:
|
|
1343
|
+
self._data_access(owner, name, recv, line, kf, decl)
|
|
1344
|
+
self._how = self._recv = None
|
|
1345
|
+
targets = self._targets(name, recv, kf, decl)
|
|
1346
|
+
if not targets:
|
|
1347
|
+
self.st["calls_unresolved"] += 1
|
|
1348
|
+
continue
|
|
1349
|
+
how = {"binding": self._how} if self._how else {}
|
|
1350
|
+
if acc:
|
|
1351
|
+
how["accessor"] = acc
|
|
1352
|
+
# the receiver's class when the member was found on an ancestor: `impact Sub.m` narrows by it (#62)
|
|
1353
|
+
if self._recv is not None and any(t.kind != "class" and t.cls and t.cls != self._recv.fqn for t in targets):
|
|
1354
|
+
how["recv"] = [self._recv.id]
|
|
1355
|
+
if self._how == "candidate":
|
|
1356
|
+
how["candidates"] = len(targets)
|
|
1357
|
+
self.st["call_candidate_edges"] += len(targets)
|
|
1358
|
+
br = self.call_branch.get((owner, line, name), {})
|
|
1359
|
+
for t in targets[:MAX_CANDIDATES if self._how == "candidate" else 3]:
|
|
1360
|
+
if t.kind == "class":
|
|
1361
|
+
self.b.add_edge(owner, t.id, "INSTANTIATES", kf.rel, line, HEURISTIC, **br)
|
|
1362
|
+
ctor = [m for m in self.members.get(t.fqn, {}).get("init", [])]
|
|
1363
|
+
self.b.add_edge(owner, t.id, "USES_TYPE", kf.rel, line, HEURISTIC, how="constructor call")
|
|
1364
|
+
for m in ctor:
|
|
1365
|
+
self.b.add_edge(owner, m.id, "CALLS", kf.rel, line, HEURISTIC)
|
|
1366
|
+
else:
|
|
1367
|
+
composable = br and any(a[0] == "Composable" for a in t.annotations)
|
|
1368
|
+
self.b.add_edge(owner, t.id, "CALLS", kf.rel, line, HEURISTIC, **how, **(br if composable else {}))
|
|
1369
|
+
self.st["calls_resolved"] += 1
|
|
1370
|
+
self._resolve_reads()
|
|
1371
|
+
self._resolve_fields()
|
|
1372
|
+
self._resolve_copies()
|
|
1373
|
+
|
|
1374
|
+
def _copy_call(self, c, kf: KFile, owner: str, decl: Decl | None):
|
|
1375
|
+
"""`state.copy(loading = true)` on a data class: a write of each named field, `via: copy` (#88)."""
|
|
1376
|
+
if not self.field_names or not c.children or c.children[0].type != "navigation_expression":
|
|
1377
|
+
return
|
|
1378
|
+
nav = c.children[0]
|
|
1379
|
+
kids = [x for x in nav.children if x.type != "."]
|
|
1380
|
+
if len(kids) != 2 or kids[1].type != "identifier" or self.t(kids[1]) != "copy":
|
|
1381
|
+
return
|
|
1382
|
+
args = next((x for x in c.children if x.type == "value_arguments"), None)
|
|
1383
|
+
named = []
|
|
1384
|
+
for a in (args.children if args is not None else []):
|
|
1385
|
+
if a.type == "value_argument":
|
|
1386
|
+
ids = [x for x in a.children if x.type == "identifier"]
|
|
1387
|
+
eq = next((x for x in a.children if x.type == "="), None)
|
|
1388
|
+
if ids and eq is not None and ids[0].start_byte < eq.start_byte:
|
|
1389
|
+
named.append((self.t(ids[0]), a.start_point[0] + 1))
|
|
1390
|
+
if named:
|
|
1391
|
+
self.copies.append((owner, (kids[0].type, self.t(kids[0])), named, kf, decl))
|
|
1392
|
+
|
|
1393
|
+
def _resolve_copies(self):
|
|
1394
|
+
"""A receiver of known type binds `resolved`. Otherwise (`it.copy(...)` in `update { }`) the one data class
|
|
1395
|
+
whose fields include every named argument binds `heuristic`, `binding: name`; none or several bind nothing."""
|
|
1396
|
+
for owner, (rtype, rt), named, kf, decl in self.copies:
|
|
1397
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
|
|
1398
|
+
rname = re.sub(r"[?!]", "", rt).split(".")[-1].strip()
|
|
1399
|
+
tyname = decl.types.get(rname) if decl is not None else None
|
|
1400
|
+
if tyname is None and cls is not None:
|
|
1401
|
+
tyname = self._field_type(cls, rname)
|
|
1402
|
+
if tyname is None and decl is not None and decl.kind != "class" and rtype == "identifier":
|
|
1403
|
+
tyname = self._local_type(decl, rname, kf)
|
|
1404
|
+
tc = self._class_of(re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1], kf) if tyname else None
|
|
1405
|
+
conf, extra = RESOLVED, {}
|
|
1406
|
+
if tc is None:
|
|
1407
|
+
want = {n for n, _ in named}
|
|
1408
|
+
cands = [k for k, f in self.fields.items() if want <= set(f)
|
|
1409
|
+
and "data" in getattr(self.classes.get(k), "modifiers", set())]
|
|
1410
|
+
if len(cands) != 1:
|
|
1411
|
+
self.st["copy_unresolved"] += 1
|
|
1412
|
+
continue
|
|
1413
|
+
tc, conf, extra = self.classes.get(cands[0]), HEURISTIC, {"binding": "name"}
|
|
1414
|
+
for n, line in named:
|
|
1415
|
+
fid = self._field_member(tc, n)
|
|
1416
|
+
if fid and fid != owner:
|
|
1417
|
+
self.b.add_edge(owner, fid, "WRITES_PROP", kf.rel, line, conf, receiver=rt[:40], via="copy", **extra)
|
|
1418
|
+
self.st["stored_property_writes"] += 1
|
|
1419
|
+
|
|
1420
|
+
def _field_member(self, cls: Decl | None, name: str, depth: int = 0) -> str | None:
|
|
1421
|
+
if cls is None or depth > 6:
|
|
1422
|
+
return None
|
|
1423
|
+
if name in self.fields.get(cls.fqn, {}):
|
|
1424
|
+
return self.fields[cls.fqn][name]
|
|
1425
|
+
for s_ in cls.supers:
|
|
1426
|
+
sc = self.classes.get(s_) or (self.class_short.get(s_) or [None])[0]
|
|
1427
|
+
if sc is not None and sc is not cls:
|
|
1428
|
+
r = self._field_member(sc, name, depth + 1)
|
|
1429
|
+
if r:
|
|
1430
|
+
return r
|
|
1431
|
+
return None
|
|
1432
|
+
|
|
1433
|
+
def _resolve_fields(self):
|
|
1434
|
+
"""READS_PROP / WRITES_PROP to stored-property field nodes (#88): `x` / `this.x` inside the class (or a
|
|
1435
|
+
subclass), `v.x` where the type of `v` is known (a parameter, a property, a local `val v = T(...)`). An
|
|
1436
|
+
unknown receiver binds nothing: stored names are far too common."""
|
|
1437
|
+
for owner, name, recv, line, kf, decl, write in self.frefs:
|
|
1438
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.cls else (
|
|
1439
|
+
decl if decl is not None and decl.kind == "class" else None)
|
|
1440
|
+
fid = None
|
|
1441
|
+
rt = recv[1] if recv is not None else None
|
|
1442
|
+
if recv is None or rt == "this":
|
|
1443
|
+
if recv is None and self._shadowed_local(decl, name, kf):
|
|
1444
|
+
continue
|
|
1445
|
+
fid = self._field_member(cls, name)
|
|
1446
|
+
elif recv[0] in ("identifier", "this_expression") or re.fullmatch(r"[\w.]+", rt or ""):
|
|
1447
|
+
rname = rt.split(".")[-1].strip()
|
|
1448
|
+
tyname = decl.types.get(rname) if decl is not None else None
|
|
1449
|
+
if tyname is None and cls is not None:
|
|
1450
|
+
tyname = self._field_type(cls, rname)
|
|
1451
|
+
if tyname is None and decl is not None and decl.kind != "class":
|
|
1452
|
+
tyname = self._local_type(decl, rname, kf)
|
|
1453
|
+
if tyname:
|
|
1454
|
+
tc = self._class_of(re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1], kf)
|
|
1455
|
+
fid = self._field_member(tc, name)
|
|
1456
|
+
if fid is None or fid == owner:
|
|
1457
|
+
self.st["stored_property_refs_unresolved"] += 1
|
|
1458
|
+
continue
|
|
1459
|
+
self.b.add_edge(owner, fid, "WRITES_PROP" if write else "READS_PROP", kf.rel, line, RESOLVED,
|
|
1460
|
+
receiver="this" if recv is None or rt == "this" else rt[:40],
|
|
1461
|
+
**({"via": write} if isinstance(write, str) else {}))
|
|
1462
|
+
self.st["stored_property_writes" if write else "stored_property_reads"] += 1
|
|
1463
|
+
|
|
1464
|
+
def _local_type(self, decl: Decl, name: str, kf: KFile) -> str | None:
|
|
1465
|
+
"""`val d = Cart(...)` inside the function body: the constructed type of a local."""
|
|
1466
|
+
src = "\n".join(kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end])
|
|
1467
|
+
m = re.search(r"\b(?:val|var)\s+" + re.escape(name) + r"\s*(?::\s*([A-Z][\w.]*))?\s*=\s*([A-Z][\w.]*)\s*\(", src)
|
|
1468
|
+
return (m.group(1) or m.group(2)) if m else None
|
|
1469
|
+
|
|
1470
|
+
def _prop_member(self, cls: Decl, name: str, depth: int = 0) -> list[Decl]:
|
|
1471
|
+
hit = self.prop_members.get(cls.fqn, {}).get(name)
|
|
1472
|
+
if hit:
|
|
1473
|
+
return hit
|
|
1474
|
+
if depth > 6:
|
|
1475
|
+
return []
|
|
1476
|
+
for s_ in cls.supers:
|
|
1477
|
+
sc = self.classes.get(s_) or (self.class_short.get(s_) or [None])[0]
|
|
1478
|
+
if sc is not None and sc is not cls:
|
|
1479
|
+
r = self._prop_member(sc, name, depth + 1)
|
|
1480
|
+
if r:
|
|
1481
|
+
return r
|
|
1482
|
+
return []
|
|
1483
|
+
|
|
1484
|
+
def _ext_props(self, name: str, ty: str) -> list[Decl]:
|
|
1485
|
+
return [d for d in self.props_by_name.get(name, []) if d.receiver and d.receiver.split(".")[-1] == ty]
|
|
1486
|
+
|
|
1487
|
+
def _resolve_reads(self):
|
|
1488
|
+
"""Property reads / writes -> CALLS with `property: read | write`, on the receiver rules of calls (#83): a known
|
|
1489
|
+
receiver type binds exactly or not at all; an unknown receiver binds only a name no stored property shares."""
|
|
1490
|
+
LIT = {"integer_literal": "Int", "long_literal": "Long", "string_literal": "String", "real_literal": "Double",
|
|
1491
|
+
"boolean_literal": "Boolean", "character_literal": "Char"}
|
|
1492
|
+
for owner, name, recv, line, kf, decl, mode in self.reads:
|
|
1493
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.cls else (
|
|
1494
|
+
decl if decl is not None and decl.kind == "class" else None)
|
|
1495
|
+
how, r = None, []
|
|
1496
|
+
rt = recv[1] if recv is not None else None
|
|
1497
|
+
if recv is None or rt == "this":
|
|
1498
|
+
if recv is None and self._shadowed_local(decl, name, kf):
|
|
1499
|
+
continue # not `_shadowed`: `{ x = 1` and `f(x)` do not declare `x` (#105)
|
|
1500
|
+
if cls is not None:
|
|
1501
|
+
r = self._prop_member(cls, name)
|
|
1502
|
+
if not r and decl is not None and decl.receiver:
|
|
1503
|
+
rc = self._class_of(decl.receiver.split(".")[-1], kf)
|
|
1504
|
+
r = self._prop_member(rc, name) if rc is not None else []
|
|
1505
|
+
r = r or self._ext_props(name, decl.receiver.split(".")[-1])
|
|
1506
|
+
if not r and recv is None:
|
|
1507
|
+
if cls is not None and (name in cls.types or self._field_type(cls, name)):
|
|
1508
|
+
continue # a stored property of the class shadows a top-level one
|
|
1509
|
+
fq = kf.imports.get(name)
|
|
1510
|
+
r = [d for d in self.props_by_name[name] if d.kind == "function" and not d.receiver
|
|
1511
|
+
and (d.fqn == fq if fq else d.fqn == f"{kf.package}.{name}")]
|
|
1512
|
+
else:
|
|
1513
|
+
rname = re.sub(r"[?!]", "", rt).split(".")[-1].strip()
|
|
1514
|
+
tyname = LIT.get(recv[0])
|
|
1515
|
+
if recv[0] == "number_literal":
|
|
1516
|
+
tyname = "Long" if rt.rstrip().endswith(("L", "l")) else ("Double" if "." in rt else "Int")
|
|
1517
|
+
if tyname is None and decl is not None:
|
|
1518
|
+
tyname = decl.types.get(rname)
|
|
1519
|
+
if tyname is None and cls is not None:
|
|
1520
|
+
tyname = self._field_type(cls, rname)
|
|
1521
|
+
if tyname is None and rname[:1].isupper():
|
|
1522
|
+
tyname = re.sub(r"\s*[({].*$", "", rname, flags=re.S)
|
|
1523
|
+
if tyname is None and rname in self.props_by_name: # `cart.label.length` style chains
|
|
1524
|
+
pt = {self.props[d.id].get("type") for d in self.props_by_name[rname]} - {None}
|
|
1525
|
+
tyname = pt.pop() if len(pt) == 1 else None
|
|
1526
|
+
if tyname:
|
|
1527
|
+
short = re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1]
|
|
1528
|
+
tc = self._class_of(short, kf)
|
|
1529
|
+
r = (self._prop_member(tc, name) if tc is not None else []) or self._ext_props(name, short)
|
|
1530
|
+
if not r and tc is not None:
|
|
1531
|
+
comp = self.classes.get(f"{tc.fqn}.Companion")
|
|
1532
|
+
r = self._prop_member(comp, name) if comp else []
|
|
1533
|
+
else:
|
|
1534
|
+
# extension / top-level properties never bind by name: `x.size` is far more often the library's
|
|
1535
|
+
cands = list({d.id: d for d in self.props_by_name[name] if d.cls}.values())
|
|
1536
|
+
if len(cands) == 1 and name not in self.stored_names and len(name) > 3:
|
|
1537
|
+
r, how = cands, "name"
|
|
1538
|
+
if mode == "write":
|
|
1539
|
+
r = [d for d in r if {"set", "delegate"} & set(self.props[d.id]["accessors"])]
|
|
1540
|
+
for t in r[:3]:
|
|
1541
|
+
if t.id == owner:
|
|
1542
|
+
continue
|
|
1543
|
+
m = mode
|
|
1544
|
+
if m == "read_write" and not {"set", "delegate"} & set(self.props[t.id]["accessors"]):
|
|
1545
|
+
m = "read" # only the getter is a node's code: the setter is the default
|
|
1546
|
+
self.b.add_edge(owner, t.id, "CALLS", kf.rel, line, HEURISTIC, property=m,
|
|
1547
|
+
**({"binding": how} if how else {}))
|
|
1548
|
+
self.st[{"read": "property_reads", "write": "property_writes"}.get(m, "property_read_writes")] += 1
|
|
1549
|
+
|
|
1550
|
+
def _targets(self, name: str, recv: str | None, kf: KFile, decl: Decl | None) -> list[Decl]:
|
|
1551
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
|
|
1552
|
+
if recv is None or recv == "this":
|
|
1553
|
+
if cls is not None:
|
|
1554
|
+
r = [d for d in self._member(cls, name) if d.kind != "class"]
|
|
1555
|
+
if r:
|
|
1556
|
+
self._recv = cls
|
|
1557
|
+
return self._same_set(r, kf)
|
|
1558
|
+
if decl is not None and decl.receiver: # extension function: members of its receiver
|
|
1559
|
+
rc = self._class_of(decl.receiver.split(".")[-1], kf)
|
|
1560
|
+
if rc is not None:
|
|
1561
|
+
r = self._member(rc, name)
|
|
1562
|
+
if r:
|
|
1563
|
+
return r
|
|
1564
|
+
if name[:1].isupper():
|
|
1565
|
+
c = self._class_of(name, kf)
|
|
1566
|
+
if c is not None:
|
|
1567
|
+
return [c]
|
|
1568
|
+
fq = kf.imports.get(name)
|
|
1569
|
+
if fq:
|
|
1570
|
+
r = [d for d in self.by_name.get(name, []) if d.fqn == fq and d.kind == "function"]
|
|
1571
|
+
if r:
|
|
1572
|
+
return r
|
|
1573
|
+
same = [d for d in self.by_name.get(name, []) if d.kind == "function" and d.fqn == f"{kf.package}.{name}"
|
|
1574
|
+
and "actual" not in d.modifiers]
|
|
1575
|
+
if same:
|
|
1576
|
+
return same
|
|
1577
|
+
cands = [d for d in self.by_name.get(name, []) if d.kind == "function" and "actual" not in d.modifiers]
|
|
1578
|
+
return cands if len(cands) == 1 else []
|
|
1579
|
+
rname = re.sub(r"[?!]", "", recv).split(".")[-1].strip()
|
|
1580
|
+
tyname = None
|
|
1581
|
+
if decl is not None:
|
|
1582
|
+
tyname = decl.types.get(rname)
|
|
1583
|
+
if tyname is None and cls is not None:
|
|
1584
|
+
tyname = self._field_type(cls, rname)
|
|
1585
|
+
if tyname is None and rname[:1].isupper():
|
|
1586
|
+
tyname = re.sub(r"\s*[({].*$", "", rname, flags=re.S) # `WishController().add(x)`: a WishController
|
|
1587
|
+
if tyname:
|
|
1588
|
+
tc = self._class_of(tyname, kf)
|
|
1589
|
+
if tc is not None:
|
|
1590
|
+
r = self._member(tc, name)
|
|
1591
|
+
if not r:
|
|
1592
|
+
comp = self.classes.get(f"{tc.fqn}.Companion")
|
|
1593
|
+
r = self._member(comp, name) if comp else []
|
|
1594
|
+
if r:
|
|
1595
|
+
self._recv = tc
|
|
1596
|
+
return self._same_set(r, kf)
|
|
1597
|
+
return []
|
|
1598
|
+
short = re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1]
|
|
1599
|
+
if len(short) > 1 and short[:1].isupper() and not self.class_short.get(short):
|
|
1600
|
+
# a library type (`DataStoreFactory.create(...)`, `Headers.build { }`, `client: HttpClient`): a known
|
|
1601
|
+
# receiver type binds exactly or not at all (#83), never by name to a project method
|
|
1602
|
+
self.st["calls_library_receiver"] += 1
|
|
1603
|
+
return []
|
|
1604
|
+
# unknown receiver: the name decides. One method of that name is a `name` binding; several (on up to
|
|
1605
|
+
# MAX_CANDIDATES classes) are `candidate` edges to each (#83 item 6): kept, never a confident edge, left out
|
|
1606
|
+
# of divergence and flagged by `cg tests` / impact
|
|
1607
|
+
cands = list({d.id: d for d in self.by_name.get(name, []) if d.kind == "method"
|
|
1608
|
+
and "actual" not in d.modifiers}.values())
|
|
1609
|
+
if not cands or len(name) <= 3:
|
|
1610
|
+
return []
|
|
1611
|
+
if len(cands) > MAX_CANDIDATES:
|
|
1612
|
+
self.st["calls_too_ambiguous"] += 1
|
|
1613
|
+
return []
|
|
1614
|
+
self._how = "name" if len(cands) == 1 else "candidate"
|
|
1615
|
+
return cands
|
|
1616
|
+
|
|
1617
|
+
@staticmethod
|
|
1618
|
+
def _same_set(cands: list[Decl], kf: KFile) -> list[Decl]:
|
|
1619
|
+
"""Members of an `expect` class and its `actual`s share one name (#86): code in a platform source set calls the
|
|
1620
|
+
member of its own `actual`, common code the `expect` one; an `actual` from another platform's set never."""
|
|
1621
|
+
if len(cands) < 2 or not any("actual" in d.modifiers for d in cands):
|
|
1622
|
+
return cands
|
|
1623
|
+
same = [d for d in cands if d.file == kf.rel]
|
|
1624
|
+
if same:
|
|
1625
|
+
return same
|
|
1626
|
+
own = [d for d in cands if "actual" in d.modifiers and kf.source_set and d.id.endswith(f"@{kf.source_set}")]
|
|
1627
|
+
return own or [d for d in cands if "actual" not in d.modifiers] or cands
|
|
1628
|
+
|
|
1629
|
+
def _field_type(self, cls: Decl, name: str, depth=0):
|
|
1630
|
+
if name in cls.types:
|
|
1631
|
+
return cls.types[name]
|
|
1632
|
+
if cls.cls and depth < 4 and cls.cls in self.classes:
|
|
1633
|
+
return self._field_type(self.classes[cls.cls], name, depth + 1)
|
|
1634
|
+
return None
|
|
1635
|
+
|
|
1636
|
+
def _hierarchy(self):
|
|
1637
|
+
for d in list(self.decls.values()):
|
|
1638
|
+
if d.kind != "class":
|
|
1639
|
+
continue
|
|
1640
|
+
for s in d.supers:
|
|
1641
|
+
sc = self.classes.get(s) or (self.class_short.get(s) or [None])[0]
|
|
1642
|
+
if sc is None:
|
|
1643
|
+
if s in WORKER_BASES:
|
|
1644
|
+
self._entry_class(d, "queue_job", f"{s} subclass")
|
|
1645
|
+
continue
|
|
1646
|
+
kind = "IMPLEMENTS" if self.b.nodes[sc.id].attrs.get("kotlin_kind") == "interface" else "EXTENDS"
|
|
1647
|
+
self.b.add_edge(d.id, sc.id, kind, d.file, d.line, HEURISTIC)
|
|
1648
|
+
for nm, ms in self.members.get(d.fqn, {}).items():
|
|
1649
|
+
for base in self.members.get(sc.fqn, {}).get(nm, []):
|
|
1650
|
+
for m in ms:
|
|
1651
|
+
ek = "IMPLEMENTED_BY" if kind == "IMPLEMENTS" or "abstract" in base.modifiers else "OVERRIDDEN_BY"
|
|
1652
|
+
self.b.add_edge(base.id, m.id, ek, m.file, m.line, HEURISTIC)
|
|
1653
|
+
# expect -> actual
|
|
1654
|
+
for d in list(self.decls.values()):
|
|
1655
|
+
if "actual" in d.modifiers:
|
|
1656
|
+
base = self.decls.get(d.id.split("@")[0])
|
|
1657
|
+
if base is not None and "expect" in base.modifiers:
|
|
1658
|
+
self.b.add_edge(base.id, d.id, "IMPLEMENTED_BY", d.file, d.line, EXACT, how="expect/actual")
|
|
1659
|
+
self.st["expect_actual"] += 1
|
|
1660
|
+
|
|
1661
|
+
def _entry_class(self, d: Decl, kind: str, why: str):
|
|
1662
|
+
n = self.b.nodes[d.id]
|
|
1663
|
+
n.entry_kind = n.entry_kind or kind
|
|
1664
|
+
n.attrs["entry_reason"] = why
|
|
1665
|
+
for nm, ms in self.members.get(d.fqn, {}).items():
|
|
1666
|
+
if LIFECYCLE.match(nm):
|
|
1667
|
+
for m in ms:
|
|
1668
|
+
self.b.add_edge(d.id, m.id, "REFERENCES_FN", m.file, m.line, EXACT, how="framework lifecycle")
|
|
1669
|
+
self.st[f"entries_{kind}"] += 1
|
|
1670
|
+
|
|
1671
|
+
# ------------------------------------------------------------------ typed navigation / resources
|
|
1672
|
+
def _typed_block(self, name: str, typ: str, c, lam, kf: KFile, owner: str, decl: Decl | None, ctx: dict) -> bool:
|
|
1673
|
+
"""`composable<Route>(...) { }` / Navigation 3 `entry<Key> { }` as typed pages; Ktor type-safe resources
|
|
1674
|
+
`get<Articles> { }` inside routing as routes (path from the @Resource class)."""
|
|
1675
|
+
short = typ.split(".")[-1].split("<")[0].strip()
|
|
1676
|
+
if not re.fullmatch(r"[A-Z]\w*", short or ""):
|
|
1677
|
+
return False
|
|
1678
|
+
if name in ("composable", "entry", "dialog"):
|
|
1679
|
+
pid = self._page(kf, short, c, typed=True)
|
|
1680
|
+
self._refs(lam, kf, pid, decl, ctx)
|
|
1681
|
+
return True
|
|
1682
|
+
if name in VERBS and ctx.get("routing"):
|
|
1683
|
+
path = self._resource_path(short, kf)
|
|
1684
|
+
if path is None:
|
|
1685
|
+
return False
|
|
1686
|
+
hid = self._ktor_route(kf, name.upper(), join_path(ctx["prefix"], path), c, owner, ctx)
|
|
1687
|
+
self.st["routes_ktor_resources"] += 1
|
|
1688
|
+
self._refs(lam, kf, hid, decl, {**ctx, "routing": False})
|
|
1689
|
+
return True
|
|
1690
|
+
return False
|
|
1691
|
+
|
|
1692
|
+
def _resource_path(self, short: str, kf: KFile, depth: int = 0) -> str | None:
|
|
1693
|
+
"""Path of a Ktor @Resource class; a `parent` property typed with another resource prefixes it."""
|
|
1694
|
+
cls = self._class_of(short, kf)
|
|
1695
|
+
if cls is None or depth > 6:
|
|
1696
|
+
return None
|
|
1697
|
+
ann = next((a for a in cls.annotations if a[0] == "Resource"), None)
|
|
1698
|
+
if ann is None or ann[1] is None:
|
|
1699
|
+
return None
|
|
1700
|
+
path = template('"' + ann[1] + '"')
|
|
1701
|
+
ptype = cls.types.get("parent")
|
|
1702
|
+
if ptype:
|
|
1703
|
+
pp = self._resource_path(ptype.split(".")[-1].rstrip("?"), kf, depth + 1)
|
|
1704
|
+
if pp is not None:
|
|
1705
|
+
return join_path(pp, path)
|
|
1706
|
+
if cls.cls and depth == 0 and not path.startswith("/"): # nested class of a resource without `parent`
|
|
1707
|
+
outer = self.classes.get(cls.cls)
|
|
1708
|
+
pp = self._resource_path(outer.name, kf, depth + 1) if outer else None
|
|
1709
|
+
if pp is not None:
|
|
1710
|
+
return join_path(pp, path)
|
|
1711
|
+
return path
|
|
1712
|
+
|
|
1713
|
+
# ------------------------------------------------------------------ Gradle buildConfigField
|
|
1714
|
+
def _build_config(self, root: Path, files: list[str]) -> dict[str, list[str]]:
|
|
1715
|
+
"""String buildConfigField values of the Gradle build files (`buildConfigField("String", "BASE_URL",
|
|
1716
|
+
"\\"https://...\\"")`): Retrofit `baseUrl(BuildConfig.BASE_URL)` resolves through them."""
|
|
1717
|
+
out: dict[str, list[str]] = defaultdict(list)
|
|
1718
|
+
cands = [f for f in files if f.endswith(("build.gradle.kts",))]
|
|
1719
|
+
for d in {Path(f).parent for f in files if f.endswith(".kt")}:
|
|
1720
|
+
for up in [d, *d.parents][:8]:
|
|
1721
|
+
g = up / "build.gradle"
|
|
1722
|
+
if (root / g).is_file():
|
|
1723
|
+
cands.append(str(g))
|
|
1724
|
+
rx = re.compile(r'buildConfigField\s*\(?\s*["\']String["\']\s*,\s*["\'](\w+)["\']\s*,\s*'
|
|
1725
|
+
r'(?:"\\"([^"\\]*)\\""|\'"([^"\']*)"\')')
|
|
1726
|
+
for f in sorted(set(cands)):
|
|
1727
|
+
try:
|
|
1728
|
+
txt = (root / f).read_text(errors="replace")
|
|
1729
|
+
except OSError:
|
|
1730
|
+
continue
|
|
1731
|
+
for m in rx.finditer(txt):
|
|
1732
|
+
v = m.group(2) if m.group(2) is not None else m.group(3)
|
|
1733
|
+
if v not in out[m.group(1)]:
|
|
1734
|
+
out[m.group(1)].append(v)
|
|
1735
|
+
return dict(out)
|
|
1736
|
+
|
|
1737
|
+
# ------------------------------------------------------------------ Spring Data / Exposed tables
|
|
1738
|
+
def _data_models(self, kfiles: list[KFile]):
|
|
1739
|
+
self.repos: dict[str, str] = {} # repository interface short name -> table
|
|
1740
|
+
self.exposed: dict[str, str] = {} # Exposed table object short name -> table
|
|
1741
|
+
rx_repo = re.compile(r"\binterface\s+(\w+)\s*(?:<[^>{]*>)?\s*:[^{]*?\b" + REPO_SUPERS + r"\s*<\s*([\w.]+)")
|
|
1742
|
+
rx_tab = re.compile(r"\bobject\s+(\w+)\s*:\s*(?:[\w.]+\.)?" + EXPOSED_TABLES + r"(?:<[^>]*>)?\s*\(\s*(?:name\s*=\s*)?"
|
|
1743
|
+
r"(\"[^\"]*\")?")
|
|
1744
|
+
for kf in kfiles:
|
|
1745
|
+
txt = kf.src.decode("utf-8", "replace")
|
|
1746
|
+
if "Repository" in txt:
|
|
1747
|
+
for m in rx_repo.finditer(txt):
|
|
1748
|
+
self.repos[m.group(1)] = self._entity_table(m.group(2).split(".")[-1], kf)
|
|
1749
|
+
if "Table" in txt and "exposed" in txt:
|
|
1750
|
+
for m in rx_tab.finditer(txt):
|
|
1751
|
+
name = m.group(2).strip('"') if m.group(2) else re.sub(r"Table$", "", m.group(1))
|
|
1752
|
+
self.exposed[m.group(1)] = name or m.group(1)
|
|
1753
|
+
|
|
1754
|
+
def _entity_table(self, entity: str, kf: KFile) -> str:
|
|
1755
|
+
"""@Table(name = "owners") on the entity, else Spring Boot's default naming (CamelCase -> snake_case)."""
|
|
1756
|
+
cls = self._class_of(entity, kf)
|
|
1757
|
+
if cls is not None:
|
|
1758
|
+
ann = next((a for a in cls.annotations if a[0] in ("Table", "Document")), None)
|
|
1759
|
+
if ann is not None and ann[1]:
|
|
1760
|
+
return ann[1]
|
|
1761
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", entity).lower()
|
|
1762
|
+
|
|
1763
|
+
def _table(self, name: str, via: str) -> str:
|
|
1764
|
+
tid = self.b.add_node("table", name, lang="sql", attrs={"inferred": True, "via": via})
|
|
1765
|
+
return tid
|
|
1766
|
+
|
|
1767
|
+
def _data_access(self, owner: str, name: str, recv: str | None, line: int, kf: KFile, decl: Decl | None):
|
|
1768
|
+
if not (self.repos or self.exposed):
|
|
1769
|
+
return
|
|
1770
|
+
kind = "WRITES_TABLE" if DATA_WRITE.match(name) else "READS_TABLE" if DATA_READ.match(name) else None
|
|
1771
|
+
if kind is None:
|
|
1772
|
+
return
|
|
1773
|
+
rname = re.sub(r"[?!]", "", recv).split(".")[-1].strip() if recv else None
|
|
1774
|
+
if rname in self.exposed: # Users.selectAll() / Users.insert { }
|
|
1775
|
+
self.b.add_edge(owner, self._table(self.exposed[rname], "exposed"), kind, kf.rel, line, RESOLVED,
|
|
1776
|
+
via=f"exposed {name}")
|
|
1777
|
+
self.st["table_access_exposed"] += 1
|
|
1778
|
+
return
|
|
1779
|
+
repo = None
|
|
1780
|
+
if recv is None or recv == "this":
|
|
1781
|
+
if decl is not None and decl.cls and decl.cls.split(".")[-1] in self.repos: # default method of the repo
|
|
1782
|
+
repo = decl.cls.split(".")[-1]
|
|
1783
|
+
else:
|
|
1784
|
+
cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
|
|
1785
|
+
ty = decl.types.get(rname) if decl is not None else None
|
|
1786
|
+
if ty is None and cls is not None:
|
|
1787
|
+
ty = self._field_type(cls, rname)
|
|
1788
|
+
ty = (ty or "").split(".")[-1].split("<")[0].rstrip("?")
|
|
1789
|
+
if ty in self.repos:
|
|
1790
|
+
repo = ty
|
|
1791
|
+
if repo is not None: # owners.findById(id) on an OwnerRepository
|
|
1792
|
+
self.b.add_edge(owner, self._table(self.repos[repo], "spring-data"), kind, kf.rel, line, RESOLVED,
|
|
1793
|
+
via=f"{repo}.{name}")
|
|
1794
|
+
self.st["table_access_spring_data"] += 1
|
|
1795
|
+
|
|
1796
|
+
# ------------------------------------------------------------------ Spring Security
|
|
1797
|
+
def _security_rules(self, kfiles: list[KFile]):
|
|
1798
|
+
"""URL rules of SecurityFilterChain beans (`requestMatchers("/admin/**").hasRole("ADMIN")`, Kotlin DSL
|
|
1799
|
+
`authorize("/admin/**", hasRole("ADMIN"))`), first match wins, as guards on the Spring routes they cover."""
|
|
1800
|
+
rules = []
|
|
1801
|
+
rx_chain = re.compile(r"\b(?:requestMatchers|antMatchers|mvcMatchers|pathMatchers)\s*\(([^()]*)\)\s*\.\s*"
|
|
1802
|
+
+ SEC_AUTH + r"\s*\(([^()]*)\)")
|
|
1803
|
+
rx_dsl = re.compile(r"\bauthorize\s*\(\s*(?:HttpMethod\.(\w+)\s*,\s*)?(\"[^\"]*\"|anyRequest)\s*,\s*"
|
|
1804
|
+
+ SEC_AUTH + r"\b(?:\s*\(([^()]*)\))?")
|
|
1805
|
+
rx_any = re.compile(r"\banyRequest\s*\(\s*\)\s*\.\s*" + SEC_AUTH + r"\s*\(([^()]*)\)")
|
|
1806
|
+
for kf in kfiles:
|
|
1807
|
+
txt = kf.src.decode("utf-8", "replace")
|
|
1808
|
+
if "SecurityFilterChain" not in txt and "SecurityWebFilterChain" not in txt:
|
|
1809
|
+
continue
|
|
1810
|
+
found = []
|
|
1811
|
+
for m in rx_chain.finditer(txt):
|
|
1812
|
+
meth = re.search(r"HttpMethod\.(\w+)", m.group(1))
|
|
1813
|
+
pats = re.findall(r'"([^"]*)"', m.group(1))
|
|
1814
|
+
found.append((m.start(), meth.group(1).upper() if meth else None, pats, m.group(2), m.group(3)))
|
|
1815
|
+
for m in rx_dsl.finditer(txt):
|
|
1816
|
+
pats = ["/**"] if m.group(2) == "anyRequest" else [m.group(2).strip('"')]
|
|
1817
|
+
found.append((m.start(), (m.group(1) or "").upper() or None, pats, m.group(3), m.group(4) or ""))
|
|
1818
|
+
for m in rx_any.finditer(txt):
|
|
1819
|
+
found.append((m.start(), None, ["/**"], m.group(1), m.group(2)))
|
|
1820
|
+
for _, meth, pats, auth, arg in sorted(found, key=lambda x: x[0]):
|
|
1821
|
+
roles = ",".join(re.findall(r'"([^"]*)"', arg))
|
|
1822
|
+
g = None if auth == "permitAll" else (f"{auth}({roles})" if roles else auth)
|
|
1823
|
+
rules.append((meth, [self._ant(p) for p in pats if p], g, kf.rel))
|
|
1824
|
+
if not rules:
|
|
1825
|
+
return
|
|
1826
|
+
self.st["security_rules"] = len(rules)
|
|
1827
|
+
for nid, n in self.b.nodes.items():
|
|
1828
|
+
if n.kind != "route" or n.lang != "kotlin" or n.attrs.get("framework") != "spring":
|
|
1829
|
+
continue
|
|
1830
|
+
uri, meth = n.attrs.get("uri", ""), n.attrs.get("method")
|
|
1831
|
+
probe = re.sub(r"\{\w+\}", "x", uri)
|
|
1832
|
+
for rm, rxs, g, rel in rules:
|
|
1833
|
+
if rm and rm != meth:
|
|
1834
|
+
continue
|
|
1835
|
+
if any(r.fullmatch(probe) for r in rxs):
|
|
1836
|
+
if g:
|
|
1837
|
+
mw = n.attrs.setdefault("middleware", [])
|
|
1838
|
+
if g not in mw:
|
|
1839
|
+
mw.append(g)
|
|
1840
|
+
n.attrs.setdefault("security", f"SecurityFilterChain ({rel})")
|
|
1841
|
+
self.st["routes_guarded_by_security_chain"] += 1
|
|
1842
|
+
break
|
|
1843
|
+
|
|
1844
|
+
@staticmethod
|
|
1845
|
+
def _ant(p: str):
|
|
1846
|
+
"""Spring path pattern -> regex: `**` any depth, `*` one segment part, `{x}` one segment."""
|
|
1847
|
+
out, i = "", 0
|
|
1848
|
+
while i < len(p):
|
|
1849
|
+
if p.startswith("/**", i):
|
|
1850
|
+
out += r"(?:/.*)?"
|
|
1851
|
+
i += 3
|
|
1852
|
+
elif p.startswith("**", i):
|
|
1853
|
+
out += r".*"
|
|
1854
|
+
i += 2
|
|
1855
|
+
elif p[i] == "*":
|
|
1856
|
+
out += r"[^/]*"
|
|
1857
|
+
i += 1
|
|
1858
|
+
elif p[i] == "{":
|
|
1859
|
+
j = p.find("}", i)
|
|
1860
|
+
out += r"[^/]+"
|
|
1861
|
+
i = j + 1 if j > 0 else len(p)
|
|
1862
|
+
else:
|
|
1863
|
+
out += re.escape(p[i])
|
|
1864
|
+
i += 1
|
|
1865
|
+
return re.compile(out)
|
|
1866
|
+
|
|
1867
|
+
def _emit_http(self):
|
|
1868
|
+
bases = sorted(self.base_urls)
|
|
1869
|
+
base = bases[0] if len(bases) == 1 else None
|
|
1870
|
+
for r in self.http:
|
|
1871
|
+
url = r["url"]
|
|
1872
|
+
own = self.api_base.get(r.get("iface") or "")
|
|
1873
|
+
base = sorted(own)[0] if own and len(own) == 1 else (bases[0] if len(bases) == 1 else None)
|
|
1874
|
+
if r.get("relative") and base and not re.match(r"^[a-zA-Z][\w+.-]*://", url):
|
|
1875
|
+
bo, bp = split_url(base)
|
|
1876
|
+
origin, path = bo, (join_path(bp, url) if not url.startswith("/") else url)
|
|
1877
|
+
else:
|
|
1878
|
+
origin, path = split_url(url)
|
|
1879
|
+
okind = "api" if origin is None or (base and origin == split_url(base)[0]) else (
|
|
1880
|
+
"unknown" if origin.startswith("{") else "other")
|
|
1881
|
+
key = f"{r['method']} {path}" if okind in ("api", "unknown") else f"{r['method']} {origin}{path}"
|
|
1882
|
+
nid = self.b.add_node("http", key, key, fqn=key, lang="kotlin",
|
|
1883
|
+
attrs={"method": r["method"], "path": path, "client": r["client"], "origin": origin,
|
|
1884
|
+
"origin_kind": okind})
|
|
1885
|
+
self.b.add_edge(r["src"], nid, "HTTP_CALLS", r["file"], r["line"], HEURISTIC if okind != "api" else EXACT,
|
|
1886
|
+
client=r["client"], url=url, origin=origin)
|
|
1887
|
+
self.st[f"http_{r['client']}"] += 1
|
|
1888
|
+
|
|
1889
|
+
def _link_navs(self):
|
|
1890
|
+
from ...link import match_path
|
|
1891
|
+
pages = [(nid, n.attrs.get("route")) for nid, n in self.b.nodes.items() if n.kind == "page" and n.lang == "kotlin"]
|
|
1892
|
+
for owner, route, typ, file, line in self.navs:
|
|
1893
|
+
hits = []
|
|
1894
|
+
for nid, pr in pages:
|
|
1895
|
+
if typ is not None:
|
|
1896
|
+
if pr == typ:
|
|
1897
|
+
hits.append(nid)
|
|
1898
|
+
elif route is not None and pr is not None:
|
|
1899
|
+
ok, _ = match_path("/" + route.split("?")[0].lstrip("/"), "/" + pr.split("?")[0].lstrip("/"))
|
|
1900
|
+
if ok:
|
|
1901
|
+
hits.append(nid)
|
|
1902
|
+
for h in hits[:2]:
|
|
1903
|
+
self.b.add_edge(owner, h, "NAVIGATES_TO", file, line, EXACT if len(hits) == 1 else HEURISTIC)
|
|
1904
|
+
self.st["navigations"] += 1
|
|
1905
|
+
|
|
1906
|
+
# ------------------------------------------------------------------ AndroidManifest.xml
|
|
1907
|
+
def _manifests(self, project: Project):
|
|
1908
|
+
ns = "{http://schemas.android.com/apk/res/android}"
|
|
1909
|
+
rules = path_rules(project, "kotlin")
|
|
1910
|
+
for dp, dn, fn in os.walk(project.root):
|
|
1911
|
+
rd = os.path.relpath(dp, project.root).replace(os.sep, "/")
|
|
1912
|
+
rd = "" if rd == "." else rd
|
|
1913
|
+
dn[:] = rules.prune(rd, dn, dot=True)
|
|
1914
|
+
if "AndroidManifest.xml" not in fn:
|
|
1915
|
+
continue
|
|
1916
|
+
rel = f"{rd}/AndroidManifest.xml" if rd else "AndroidManifest.xml"
|
|
1917
|
+
if TEST_PATH.search(rel):
|
|
1918
|
+
continue
|
|
1919
|
+
try:
|
|
1920
|
+
root = ET.parse(os.path.join(dp, "AndroidManifest.xml")).getroot()
|
|
1921
|
+
except (ET.ParseError, OSError):
|
|
1922
|
+
continue
|
|
1923
|
+
pkg = root.get("package") or self._gradle_namespace(Path(dp))
|
|
1924
|
+
app = root.find("application")
|
|
1925
|
+
if app is None:
|
|
1926
|
+
continue
|
|
1927
|
+
for kind_tag, entry in (("activity", "ui_page"), ("activity-alias", "ui_page"), ("service", "listener"),
|
|
1928
|
+
("receiver", "listener"), ("provider", "listener")):
|
|
1929
|
+
for el in app.findall(kind_tag):
|
|
1930
|
+
nm = el.get(ns + "name") or el.get(ns + "targetActivity")
|
|
1931
|
+
if not nm:
|
|
1932
|
+
continue
|
|
1933
|
+
fq = (pkg + nm) if nm.startswith(".") and pkg else (nm if "." in nm else f"{pkg}.{nm}" if pkg else nm)
|
|
1934
|
+
d = self.classes.get(fq) or ((self.class_short.get(fq.split(".")[-1]) or [None])
|
|
1935
|
+
if len(self.class_short.get(fq.split(".")[-1]) or []) == 1 else [None])[0]
|
|
1936
|
+
if d is None:
|
|
1937
|
+
self.st["manifest_unresolved"] += 1
|
|
1938
|
+
continue
|
|
1939
|
+
self._entry_class(d, entry, f"AndroidManifest <{kind_tag}>")
|
|
1940
|
+
self.b.nodes[d.id].attrs["android_component"] = kind_tag
|
|
1941
|
+
for data in el.iter("data"):
|
|
1942
|
+
scheme, host = data.get(ns + "scheme"), data.get(ns + "host")
|
|
1943
|
+
path = data.get(ns + "path") or data.get(ns + "pathPrefix") or data.get(ns + "pathPattern") or ""
|
|
1944
|
+
if not scheme and not host:
|
|
1945
|
+
continue
|
|
1946
|
+
route = f"{scheme or '*'}://{host or '*'}{path}"
|
|
1947
|
+
pid = self.b.add_node("page", f"kotlin:deeplink:{route}", name=route, file=rel, lang="kotlin",
|
|
1948
|
+
entry_kind="ui_page", attrs={"route": route, "via": "android deep link",
|
|
1949
|
+
"activity": d.fqn})
|
|
1950
|
+
self.b.add_edge(pid, d.id, "ROUTES_TO", rel, None, EXACT)
|
|
1951
|
+
self.st["deep_links"] += 1
|
|
1952
|
+
|
|
1953
|
+
def _gradle_namespace(self, d: Path) -> str | None:
|
|
1954
|
+
for up in (d, d.parent, d.parent.parent, d.parent.parent.parent):
|
|
1955
|
+
for g in ("build.gradle.kts", "build.gradle"):
|
|
1956
|
+
f = up / g
|
|
1957
|
+
if f.is_file():
|
|
1958
|
+
m = re.search(r"namespace\s*=?\s*[\"']([\w.]+)[\"']", f.read_text(errors="replace"))
|
|
1959
|
+
if m:
|
|
1960
|
+
return m.group(1)
|
|
1961
|
+
return None
|