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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/bridges.py ADDED
@@ -0,0 +1,1701 @@
1
+ """Web / native bridges: Capacitor plugins, React Native (and Expo) native modules, Flutter platform channels, and the
2
+ process boundaries of desktop apps: Electron IPC / context bridge, Tauri commands.
3
+
4
+ A bridge call is local message passing between the JS / Dart side of an app and its Kotlin / Java / Swift / ObjC side.
5
+ It is modelled with the protocol endpoint model of #31 (epic #29), so the bridge path is
6
+
7
+ JS / Dart call site -SENDS_TO-> endpoint:<protocol>:<namespace>#<method> -RECEIVED_BY-> native method
8
+
9
+ protocol capacitor | react-native (NativeModules, TurboModules, Expo Modules: attrs.api = expo-modules)
10
+ | flutter (MethodChannel) | flutter-event (EventChannel, no method part: endpoint:flutter-event:<channel>)
11
+ | pigeon (@HostApi / @FlutterApi classes of the Pigeon definition files)
12
+ namespace Capacitor plugin name, React Native module name, Flutter channel name, Pigeon API class
13
+ direction to_native (app code sends, native receives) or to_app: a native `invokeMethod` received by a Dart
14
+ `setMethodCallHandler`, a native @FlutterApi call received by the Dart class implementing it
15
+ RECEIVED_BY one edge per native implementation, in the native file: the platform conventions of platforms.py tag it
16
+ (android/ ios/ macos/ directories, else Kotlin / Java = android, Swift / ObjC = ios), so `--platform ios`
17
+ drops the Android receivers and `impact` / `reaches` cross the bridge in both directions
18
+
19
+ The send side comes from the language plugins (TypeScript extractor facts, codegraph/plugins/dart/bridges.py); the
20
+ receive side is a source-text pass over the native files run here, after the language plugins (Java and ObjC have no
21
+ language plugin: their receivers become stub method nodes with attrs.bridge_stub). `finalize` then records per
22
+ endpoint which platforms receive it and the checks: missing_on (sent, received on some mobile targets of the project
23
+ but not on these), no_receiver (the namespace is implemented in this repo, the method is not), no_sender, external
24
+ (nothing in this repo implements the namespace: an npm / pub plugin package).
25
+
26
+ Process protocols (one app, two processes; receivers carry attrs.process instead of a platform, no missing_on):
27
+ electron-ipc endpoint:electron-ipc:<channel>; ipcRenderer.invoke / send / sendSync / postMessage and
28
+ webContents.send -> ipcMain.handle / handleOnce / on / once and ipcRenderer.on / once handlers
29
+ electron-preload endpoint:electron-preload:<key>#<member>; window.<key>.<member>() in the renderer ->
30
+ contextBridge.exposeInMainWorld('<key>', {member: ...}) in the preload script
31
+ tauri endpoint:tauri:<command> (plugin commands: endpoint:tauri:plugin:<name>|<command>, external
32
+ tauri-plugin-<name> unless the plugin crate is in the repo); invoke('cmd') from @tauri-apps/api ->
33
+ #[tauri::command] fn in the Rust core; check unregistered: not listed in generate_handler!
34
+ no_receiver for these: the other side is in the repo (some channel / command is received) but not this one.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import bisect
39
+ import fnmatch
40
+ import json
41
+ import os
42
+ import re
43
+ from collections import defaultdict
44
+ from pathlib import Path
45
+
46
+ PROTOCOLS = {
47
+ "capacitor": "Capacitor plugin method (registerPlugin / Plugins.X -> @CapacitorPlugin / CAPPlugin)",
48
+ "react-native": "React Native native module method (NativeModules / TurboModuleRegistry / requireNativeModule -> "
49
+ "@ReactMethod / RCT_EXPORT_METHOD / Expo Function)",
50
+ "flutter": "Flutter MethodChannel method (invokeMethod -> setMethodCallHandler)",
51
+ "flutter-event": "Flutter EventChannel stream (receiveBroadcastStream -> setStreamHandler)",
52
+ "pigeon": "Pigeon API method (@HostApi: Dart call -> Kotlin / Swift / Java implementation of the generated "
53
+ "interface; @FlutterApi: native call -> Dart class extending the generated API)",
54
+ "electron-ipc": "Electron IPC channel (ipcRenderer.invoke / send -> ipcMain.handle / on; webContents.send -> "
55
+ "ipcRenderer.on)",
56
+ "electron-preload": "Electron context bridge member (window.<key>.<member>() -> contextBridge.exposeInMainWorld)",
57
+ "tauri": "Tauri command (invoke('cmd') -> #[tauri::command] fn cmd registered in generate_handler!)",
58
+ "react-native-event": "React Native event (native sendEvent / RCTDeviceEventEmitter.emit / sendEventWithName / Expo "
59
+ "sendEvent -> NativeEventEmitter / DeviceEventEmitter addListener in JS)",
60
+ "capacitor-event": "Capacitor plugin event (native notifyListeners('evt') -> Plugin.addListener('evt') in JS)",
61
+ "cordova": "Cordova plugin action (cordova.exec(ok, fail, 'Service', 'action') -> CordovaPlugin.execute / CDVPlugin "
62
+ "method; services from plugin.xml / config.xml <feature>)",
63
+ }
64
+ # native -> app events: the JS listener is the receiver; no_sender only where the repo sends some event natively
65
+ EVENT_PROTOCOLS = ("react-native-event", "capacitor-event")
66
+ # protocols between processes of one app (no per-platform receivers)
67
+ PROCESS_PROTOCOLS = ("electron-ipc", "electron-preload", "tauri")
68
+ TRANSPORT = {"electron-ipc": "ipc", "tauri": "ipc", "electron-preload": "local"}
69
+ MOBILE = ("android", "ios", "macos")
70
+ APP_LANGS = ("dart", "ts") # the app side of a bridge (receivers there: a native -> app call)
71
+ # Capacitor Plugin / CAPPlugin base-class methods: handled by the bridge on every platform unless a plugin overrides them
72
+ CAP_BASE_METHODS = {"checkPermissions", "requestPermissions", "addListener", "removeAllListeners", "removeListener"}
73
+ # NativeEventEmitter plumbing (RCTEventEmitter implements it on iOS) and the TurboModule constants getter
74
+ RN_BASE_METHODS = {"addListener", "removeListeners", "getConstants"}
75
+ BASE_METHODS = {"capacitor": CAP_BASE_METHODS, "react-native": RN_BASE_METHODS}
76
+
77
+
78
+ def _flutter_folders(root: Path, rel: str | None, cache: dict) -> set | None:
79
+ """Platform folders (android/, ios/, macos/) next to the nearest pubspec.yaml above a Dart file."""
80
+ if not rel:
81
+ return None
82
+ d = Path(rel).parent
83
+ while True:
84
+ key = ("pubspec", str(d))
85
+ if key not in cache:
86
+ cache[key] = ({p for p in ("android", "ios", "macos") if (root / d / p).is_dir()}
87
+ if (root / d / "pubspec.yaml").is_file() else None)
88
+ if cache[key] is not None:
89
+ return cache[key]
90
+ if d == d.parent or str(d) in ("", "."):
91
+ return None
92
+ d = d.parent
93
+
94
+
95
+ def _codegen_platforms(root: Path, rel: str, cache: dict) -> list[str] | None:
96
+ """platforms of the nearest package.json's React Native codegenConfig (a module declared for android only)."""
97
+ d = Path(rel).parent
98
+ while True:
99
+ key = str(d)
100
+ if key not in cache:
101
+ cache[key] = None
102
+ pj = root / d / "package.json"
103
+ if pj.is_file():
104
+ try:
105
+ cfg = json.loads(pj.read_text(encoding="utf-8", errors="replace")).get("codegenConfig") or {}
106
+ plats = cfg.get("platforms") if isinstance(cfg, dict) else None
107
+ cache[key] = [p for p in plats if isinstance(p, str)] if isinstance(plats, list) else []
108
+ except (OSError, ValueError, AttributeError):
109
+ cache[key] = []
110
+ if cache[key] is not None:
111
+ return cache[key] or None
112
+ if d == d.parent or str(d) in ("", "."):
113
+ return None
114
+ d = d.parent
115
+ NATIVE_EXTS = (".kt", ".java", ".swift", ".m", ".mm")
116
+
117
+
118
+ # ------------------------------------------------------------------ protocol helpers (the #31 builder helper shape)
119
+ def endpoint_key(protocol: str, namespace: str, method: str | None = None) -> str:
120
+ """Key of endpoint:<protocol>:<name>, name = <namespace>#<method> (or the namespace alone for streams)."""
121
+ return f"{protocol}:{namespace}#{method}" if method else f"{protocol}:{namespace}"
122
+
123
+
124
+ def _endpoint(builder, protocol: str, namespace: str, method: str | None) -> str:
125
+ key = endpoint_key(protocol, namespace, method)
126
+ name = key.split(":", 1)[1]
127
+ return builder.add_node("endpoint", key, name, fqn=name,
128
+ attrs={"protocol": protocol, "transport": TRANSPORT.get(protocol, "local"), "namespace": namespace,
129
+ "method": method})
130
+
131
+
132
+ def protocol_send(builder, protocol: str, namespace: str, method: str | None, src: str, file: str | None,
133
+ line: int | None, confidence: str, test: bool = False, **attrs) -> str:
134
+ """Code `src` sends to an endpoint (SENDS_TO; test code: TEST_CALLS with orig SENDS_TO)."""
135
+ nid = _endpoint(builder, protocol, namespace, method)
136
+ n = builder.nodes[nid]
137
+ if test:
138
+ n.attrs.setdefault("test_only", True)
139
+ else:
140
+ n.attrs["test_only"] = False
141
+ a = {k: v for k, v in attrs.items() if v not in (None, [], "")}
142
+ if test:
143
+ builder.add_edge(src, nid, "TEST_CALLS", file=file, line=line, confidence=confidence, orig="SENDS_TO", **a)
144
+ else:
145
+ builder.add_edge(src, nid, "SENDS_TO", file=file, line=line, confidence=confidence, role="invoke", **a)
146
+ return nid
147
+
148
+
149
+ def protocol_receive(builder, protocol: str, namespace: str, method: str | None, handler: str, file: str | None,
150
+ line: int | None, confidence: str, **attrs) -> str:
151
+ """An endpoint is handled by `handler` (RECEIVED_BY, endpoint -> handler)."""
152
+ nid = _endpoint(builder, protocol, namespace, method)
153
+ builder.add_edge(nid, handler, "RECEIVED_BY", file=file, line=line, confidence=confidence,
154
+ **{k: v for k, v in attrs.items() if v not in (None, [], "")})
155
+ return nid
156
+
157
+
158
+ # ------------------------------------------------------------------ source text helpers
159
+ _TOKEN = re.compile(r'"(?:\\.|[^"\\\n])*"|//[^\n]*|/\*.*?\*/', re.S)
160
+
161
+
162
+ def strip_comments(src: str) -> str:
163
+ """Comments blanked (newlines kept, so offsets and line numbers stay), string literals kept."""
164
+ def rep(m):
165
+ t = m.group(0)
166
+ if t.startswith('"'):
167
+ return t
168
+ return re.sub(r"[^\n]", " ", t)
169
+ return _TOKEN.sub(rep, src)
170
+
171
+
172
+ def _blank_strings(src: str) -> str:
173
+ return re.sub(r'"(?:\\.|[^"\\\n])*"', lambda m: '"' + " " * (len(m.group(0)) - 2) + '"', src)
174
+
175
+
176
+ class Text:
177
+ def __init__(self, src: str):
178
+ self.src = strip_comments(src)
179
+ self.code = _blank_strings(self.src) # for brace matching: braces inside strings do not count
180
+ self.nl = [i for i, c in enumerate(self.src) if c == "\n"]
181
+
182
+ def line(self, pos: int) -> int:
183
+ return bisect.bisect_right(self.nl, pos - 1) + 1
184
+
185
+ def match(self, pos: int, open_: str = "{", close: str = "}") -> int:
186
+ """Offset of the bracket closing the one at pos (or the first `open_` at / after pos); len(src) if unbalanced."""
187
+ i = self.code.find(open_, pos)
188
+ if i < 0:
189
+ return len(self.code)
190
+ depth = 0
191
+ for j in range(i, len(self.code)):
192
+ c = self.code[j]
193
+ if c == open_:
194
+ depth += 1
195
+ elif c == close:
196
+ depth -= 1
197
+ if depth == 0:
198
+ return j
199
+ return len(self.code)
200
+
201
+ def args(self, open_pos: int) -> list[tuple[int, str]]:
202
+ """Top-level comma-separated arguments of the call whose `(` is at open_pos: [(offset, text)]."""
203
+ end = self.match(open_pos, "(", ")")
204
+ out, depth, start = [], 0, open_pos + 1
205
+ for j in range(open_pos + 1, end):
206
+ c = self.code[j]
207
+ if c in "([{":
208
+ depth += 1
209
+ elif c in ")]}":
210
+ depth -= 1
211
+ elif c == "," and depth == 0:
212
+ out.append((start, self.src[start:j].strip()))
213
+ start = j + 1
214
+ tail = self.src[start:end].strip()
215
+ if tail:
216
+ out.append((start, tail))
217
+ return out
218
+
219
+
220
+ CLASS_RE = re.compile(r"\b(?:class|object|struct|actor)\s+(\w+)\s*(?:<[^{;()]*?>)?\s*"
221
+ r"(?:\((?:[^()]|\([^()]*\))*\))?[^{;=]*\{") # a primary constructor may hold `= default`
222
+ OBJC_IMPL_RE = re.compile(r"@implementation\s+(\w+)")
223
+ CONST_RE = re.compile(r"\b(?:val|var|let|const\s+val|static\s+let|static\s+var|(?:public\s+|private\s+|protected\s+)?"
224
+ r"(?:static\s+)?final\s+String|String)\s+(\w+)\s*(?::\s*String\??)?\s*=\s*\"([^\"\\\n]*)\"")
225
+ OBJC_CONST_RE = re.compile(r"NSString\s*\*\s*(?:const\s+)?(\w+)\s*=\s*@\"([^\"\n]*)\"|#define\s+(\w+)\s+@\"([^\"\n]*)\"")
226
+ PACKAGE_RE = re.compile(r"^\s*package\s+([\w.]+)", re.M)
227
+
228
+
229
+ class NativeFile:
230
+ def __init__(self, rel: str, src: str):
231
+ self.rel, self.t = rel, Text(src)
232
+ self.ext = os.path.splitext(rel)[1]
233
+ self.lang = {".kt": "kotlin", ".java": "java", ".swift": "swift", ".m": "objc", ".mm": "objc"}[self.ext]
234
+ m = PACKAGE_RE.search(self.t.src)
235
+ self.package = m.group(1) if m and self.lang in ("kotlin", "java") else None
236
+ self.classes: list[tuple[str, int, int]] = [] # (name, start, end) offsets
237
+ for m in CLASS_RE.finditer(self.t.code):
238
+ self.classes.append((m.group(1), m.start(), self.t.match(m.end() - 1)))
239
+ if self.lang == "objc":
240
+ for m in OBJC_IMPL_RE.finditer(self.t.code):
241
+ e = self.t.code.find("@end", m.end())
242
+ self.classes.append((m.group(1), m.start(), e if e >= 0 else len(self.t.code)))
243
+ self.consts = {}
244
+ for m in CONST_RE.finditer(self.t.src):
245
+ self.consts.setdefault(m.group(1), m.group(2))
246
+ for m in OBJC_CONST_RE.finditer(self.t.src):
247
+ if m.group(1):
248
+ self.consts.setdefault(m.group(1), m.group(2))
249
+ else:
250
+ self.consts.setdefault(m.group(3), m.group(4))
251
+
252
+ def class_at(self, pos: int) -> tuple[str, int, int] | None:
253
+ best = None
254
+ for c in self.classes:
255
+ if c[1] <= pos <= c[2] and (best is None or c[1] > best[1]):
256
+ best = c
257
+ return best
258
+
259
+ def class_named(self, name: str):
260
+ return next((c for c in self.classes if c[0] == name), None)
261
+
262
+
263
+ TRIGGER = re.compile(r"CapacitorPlugin|CAPPlugin|CAP_PLUGIN|ReactMethod|RCT_(?:EXPORT|EXTERN|REMAP)|ReactContextBaseJavaModule|"
264
+ r"BaseJavaModule|Native\w+Spec\b|ModuleDefinition|MethodChannel|EventChannel|MethodCallHandler|"
265
+ r"StreamHandler|isEqualToString:\s*call\.method|call\.method|@objc\s*\(|"
266
+ r"RCTEventEmitter|RCTDeviceEventEmitter|DeviceEventManagerModule|sendEventWithName|sendEvent\s*\(|emitDeviceEvent|"
267
+ r"notifyListeners|CordovaPlugin|CDVPlugin|CDVInvokedUrlCommand")
268
+
269
+
270
+ # ------------------------------------------------------------------ receivers found in native source text
271
+ class Receiver:
272
+ __slots__ = ("protocol", "namespace", "method", "file", "pos", "fn", "cls", "conf", "via", "api", "lookup")
273
+
274
+ def __init__(self, protocol, namespace, method, nf: NativeFile, pos, fn=None, cls=None, conf="resolved", via=None,
275
+ api=None, lookup=None):
276
+ self.protocol, self.namespace, self.method = protocol, namespace, method
277
+ self.file, self.pos, self.fn, self.cls, self.conf, self.via, self.api = nf, pos, fn, cls, conf, via, api
278
+ self.lookup = lookup # (swift / objc class name, method name): implemented in another file
279
+
280
+
281
+ ANN_TAIL = r"\s*(?:@\w+(?:\.\w+)*(?:\([^)]*\))?\s*)*(?:(?:public|private|protected|internal|static|final|override|open|" \
282
+ r"suspend|synchronized|native)\s+)*(?:fun\s+(?:<[^>]*>\s*)?(\w+)|[\w<>\[\],.?]+(?:\s*<[^>]*>)?\s+(\w+))\s*\("
283
+ CAP_PLUGIN_ANN = re.compile(r"@CapacitorPlugin\b\s*(\()?")
284
+ PLUGIN_METHOD = re.compile(r"@PluginMethod\b(?:\s*\([^)]*\))?" + ANN_TAIL)
285
+ REACT_METHOD = re.compile(r"@ReactMethod\b(?:\s*\([^)]*\))?" + ANN_TAIL)
286
+ JAVA_CLASS = re.compile(r"\bclass\s+(\w+)\s*(?:\([^)]*\))?\s*(?::|extends)\s*([^{]*)\{")
287
+ GET_NAME = re.compile(r"\bgetName\s*\(\s*\)\s*(?::\s*String\s*)?(?:=\s*|\{\s*return\s+)(\"([^\"]*)\"|([\w.]+))")
288
+ OVERRIDE_FUN = re.compile(r"(?:\boverride\s+fun\s+(\w+)\s*\(|@Override\s+(?:public\s+)?(?:synchronized\s+)?[\w<>\[\],.?]+\s+(\w+)\s*\()")
289
+ SPEC_SKIP = {"getName", "getConstants", "getTypedExportedConstants", "initialize", "invalidate", "onCatalystInstanceDestroy",
290
+ "canOverrideExistingModule", "hasConstants", "getExportedConstants", "toString", "equals", "hashCode",
291
+ "onHostResume", "onHostPause", "onHostDestroy", "onActivityResult", "onNewIntent", "createNativeModules",
292
+ "createViewManagers", "getReactModuleInfoProvider", "getModule"}
293
+ SWIFT_CLASS = re.compile(r"(?:@objc\s*\(\s*(\w+)\s*\)\s*)?(?:(?:public|open|final|internal|private)\s+)*class\s+(\w+)\s*:\s*([^{]*)\{")
294
+ CAP_METHOD_NAME = re.compile(r"CAPPluginMethod\s*\(\s*name:\s*\"(\w+)\"|CAPPluginMethod\s*\(\s*\"(\w+)\"")
295
+ JS_NAME = re.compile(r"\bjsName\s*=\s*\"([^\"]+)\"")
296
+ OBJC_FUNC = re.compile(r"@objc(?:\s*\([^)]*\))?\s+(?:(?:public|open|internal|private|final|override|dynamic)\s+)*func\s+(\w+)\s*\(")
297
+ SWIFT_FUNC = re.compile(r"\bfunc\s+(\w+)\s*(?:<[^>]*>)?\s*\(")
298
+ RCT_MODULE = re.compile(r"\bRCT_EXPORT_MODULE\s*\(\s*(\w*)\s*\)")
299
+ RCT_EXPORT = re.compile(r"\bRCT_(?:EXPORT_METHOD|EXPORT_BLOCKING_SYNCHRONOUS_METHOD)\s*\(\s*(\w+)|"
300
+ r"\bRCT_(?:REMAP_METHOD|REMAP_BLOCKING_SYNCHRONOUS_METHOD)\s*\(\s*(\w+)\s*,")
301
+ RCT_EXTERN_MODULE = re.compile(r"\bRCT_EXTERN_MODULE\s*\(\s*(\w+)\s*,|\bRCT_EXTERN_REMAP_MODULE\s*\(\s*(\w*)\s*,\s*(\w+)\s*,")
302
+ RCT_EXTERN_METHOD = re.compile(r"\bRCT_EXTERN_(?:_BLOCKING_SYNCHRONOUS_)?METHOD\s*\(\s*(\w+)")
303
+ EXPO_NAME = re.compile(r"\bName\s*\(\s*\"([^\"]+)\"\s*\)")
304
+ EXPO_FN = re.compile(r"\b(?:AsyncFunction|Function)\s*\(\s*\"(\w+)\"")
305
+ CHANNEL_NEW = re.compile(r"(?<![\w.])(?:new\s+)?(?:io\.flutter\.plugin\.common\.)?(Method|Event|OptionalMethod)Channel\s*\(")
306
+ SWIFT_CHANNEL_NEW = re.compile(r"\bFlutter(Method|Event)Channel\s*\(\s*name:\s*")
307
+ OBJC_CHANNEL_NEW = re.compile(r"\[\s*Flutter(Method|Event)Channel\s+(?:methodChannel|eventChannel)WithName:\s*(@\"[^\"]*\"|\w+)")
308
+ METHOD_EQ = re.compile(r"\b\w+\.method\s*==\s*\"([^\"]+)\"|\"([^\"]+)\"\s*==\s*\w+\.method\b|"
309
+ r"\"([^\"]+)\"\.equals\s*\(\s*\w+\.method\s*\)|\b\w+\.method\.equals\s*\(\s*\"([^\"]+)\"\s*\)|"
310
+ r"\[\s*@\"([^\"]+)\"\s+isEqualToString:\s*\w+\.method\s*\]|\[\s*\w+\.method\s+isEqualToString:\s*@\"([^\"]+)\"\s*\]")
311
+ SWITCH_ON = re.compile(r"\b(?:when|switch)\s*\(?\s*\w+\.method\s*\)?\s*\{")
312
+ CASE_STR = re.compile(r"(?:\bcase\s+((?:\"[^\"]+\"\s*,\s*)*\"[^\"]+\")\s*:|^\s*((?:\"[^\"]+\"\s*,\s*)*\"[^\"]+\")\s*->)", re.M)
313
+
314
+
315
+ def _str_value(nf: NativeFile, expr: str, consts: dict) -> str | None:
316
+ e = expr.strip()
317
+ if e.startswith("name:"):
318
+ e = e[5:].strip()
319
+ m = re.fullmatch(r'@?"([^"\\\n]*)"', e)
320
+ if m:
321
+ return m.group(1)
322
+ if re.fullmatch(r"[\w.]+", e):
323
+ last = e.split(".")[-1]
324
+ if last in nf.consts:
325
+ return nf.consts[last]
326
+ if e in consts:
327
+ return consts[e]
328
+ if last in consts:
329
+ return consts[last]
330
+ return None
331
+
332
+
333
+ def scan_capacitor(nf: NativeFile, out: list, objc_plugins: dict) -> None:
334
+ t = nf.t
335
+ if nf.lang in ("kotlin", "java"):
336
+ for m in CAP_PLUGIN_ANN.finditer(t.code):
337
+ name = None
338
+ if m.group(1):
339
+ end = t.match(m.end() - 1, "(", ")")
340
+ nm = re.search(r"\bname\s*=\s*\"([^\"]+)\"", t.src[m.end():end])
341
+ name = nm.group(1) if nm else None
342
+ cm = re.compile(r"\bclass\s+(\w+)").search(t.code, m.end())
343
+ if not cm:
344
+ continue
345
+ cls = nf.class_named(cm.group(1))
346
+ ns = name or cm.group(1)
347
+ lo, hi = (cls[1], cls[2]) if cls else (cm.start(), len(t.code))
348
+ for pm in PLUGIN_METHOD.finditer(t.code, lo, hi):
349
+ fn = pm.group(1) or pm.group(2)
350
+ out.append(Receiver("capacitor", ns, fn, nf, pm.start(pm.lastindex), fn=fn, cls=cm.group(1), conf="exact",
351
+ via="@PluginMethod"))
352
+ elif nf.lang == "swift":
353
+ for m in SWIFT_CLASS.finditer(t.code):
354
+ bases = m.group(3)
355
+ if not re.search(r"\bCAPPlugin\b|\bCAPBridgedPlugin\b", bases):
356
+ continue
357
+ cls = m.group(2)
358
+ end = t.match(m.end() - 1)
359
+ body = t.src[m.start():end]
360
+ jm = JS_NAME.search(body)
361
+ listed = [a or b for a, b in CAP_METHOD_NAME.findall(body)]
362
+ objc_name = m.group(1) or cls
363
+ reg = objc_plugins.get(objc_name) or objc_plugins.get(cls)
364
+ ns = jm.group(1) if jm else (reg[0] if reg else None)
365
+ if not ns:
366
+ continue
367
+ names = set(listed) | set(reg[1] if reg else ())
368
+ funcs = {f.group(1): f.start(1) for f in OBJC_FUNC.finditer(t.code, m.start(), end)}
369
+ for fn in sorted(names) if names else sorted(funcs):
370
+ if fn in funcs:
371
+ out.append(Receiver("capacitor", ns, fn, nf, funcs[fn], fn=fn, cls=cls, conf="exact",
372
+ via="CAPPluginMethod" if fn in listed else "CAP_PLUGIN_METHOD" if names else "@objc func"))
373
+
374
+
375
+ def scan_objc_capacitor(nf: NativeFile, objc_plugins: dict) -> None:
376
+ """CAP_PLUGIN(Class, "JsName", CAP_PLUGIN_METHOD(m, ...); ...) registrations (old-style Capacitor iOS plugins)."""
377
+ t = nf.t
378
+ for m in re.finditer(r"\bCAP_PLUGIN\s*\(", t.code):
379
+ args = t.args(m.end() - 1)
380
+ if len(args) < 2:
381
+ continue
382
+ cls, js = args[0][1], re.fullmatch(r'"([^"]+)"', args[1][1])
383
+ if not js:
384
+ continue
385
+ rest = t.src[args[1][0]:t.match(m.end() - 1, "(", ")")]
386
+ methods = [x.group(1) for x in re.finditer(r"CAP_PLUGIN_METHOD\s*\(\s*(\w+)", rest)]
387
+ objc_plugins[cls] = (js.group(1), methods, nf, m.start())
388
+
389
+
390
+ def scan_react_native(nf: NativeFile, out: list, spec_names: dict, extern: dict) -> None:
391
+ t = nf.t
392
+ if nf.lang in ("kotlin", "java"):
393
+ for m in JAVA_CLASS.finditer(t.code):
394
+ bases = m.group(2)
395
+ spec = re.search(r"\b(Native\w+Spec)\b", bases)
396
+ if not (spec or re.search(r"\b(?:ReactContextBaseJavaModule|BaseJavaModule)\b", bases)):
397
+ continue
398
+ cls = m.group(1)
399
+ end = t.match(m.end() - 1)
400
+ body = t.src[m.start():end]
401
+ ns = None
402
+ gm = GET_NAME.search(body)
403
+ if gm:
404
+ ns = gm.group(2) if gm.group(2) is not None else _str_value(nf, gm.group(3), _GLOBAL_CONSTS)
405
+ if not ns and spec:
406
+ ns = spec_names.get(spec.group(1)) or spec.group(1)[len("Native"):-len("Spec")]
407
+ if not ns:
408
+ ns = nf.consts.get("NAME")
409
+ if not ns:
410
+ continue
411
+ seen = set()
412
+ for rm in REACT_METHOD.finditer(t.code, m.start(), end):
413
+ fn = rm.group(1) or rm.group(2)
414
+ seen.add(fn)
415
+ out.append(Receiver("react-native", ns, fn, nf, rm.start(rm.lastindex), fn=fn, cls=cls, conf="exact",
416
+ via="@ReactMethod"))
417
+ if spec:
418
+ for om in OVERRIDE_FUN.finditer(t.code, m.start(), end):
419
+ fn = om.group(1) or om.group(2)
420
+ if fn in seen or fn in SPEC_SKIP:
421
+ continue
422
+ seen.add(fn)
423
+ out.append(Receiver("react-native", ns, fn, nf, om.start(om.lastindex), fn=fn, cls=cls, conf="exact",
424
+ via=spec.group(1)))
425
+ elif nf.lang == "objc":
426
+ for m in RCT_MODULE.finditer(t.code):
427
+ c = nf.class_at(m.start())
428
+ cls = c[0] if c else None
429
+ ns = m.group(1) or (re.sub(r"^RCT", "", cls) if cls else None)
430
+ if not ns:
431
+ continue
432
+ lo, hi = (c[1], c[2]) if c else (0, len(t.code))
433
+ for em in RCT_EXPORT.finditer(t.code, lo, hi):
434
+ fn = em.group(1) or em.group(2)
435
+ out.append(Receiver("react-native", ns, fn, nf, em.start(), fn=fn, cls=cls, conf="exact",
436
+ via="RCT_EXPORT_METHOD"))
437
+ for m in RCT_EXTERN_MODULE.finditer(t.code):
438
+ cls = m.group(1) or m.group(3)
439
+ ns = m.group(2) or cls
440
+ e = t.code.find("@end", m.end())
441
+ hi = e if e >= 0 else len(t.code)
442
+ for em in RCT_EXTERN_METHOD.finditer(t.code, m.end(), hi):
443
+ extern.setdefault(cls, []).append((ns, em.group(1), nf, em.start()))
444
+
445
+
446
+ def scan_expo(nf: NativeFile, out: list) -> None:
447
+ t = nf.t
448
+ if nf.lang not in ("kotlin", "swift") or "ModuleDefinition" not in t.code:
449
+ return
450
+ for m in re.finditer(r"\bdefinition\s*\(\s*\)", t.code):
451
+ end = t.match(m.end())
452
+ body_lo = t.code.find("{", m.end())
453
+ nm = EXPO_NAME.search(t.src, m.end(), end)
454
+ c = nf.class_at(m.start())
455
+ ns = nm.group(1) if nm else (c[0] if c else None)
456
+ if not ns or body_lo < 0:
457
+ continue
458
+ for fm in EXPO_FN.finditer(t.src, body_lo, end):
459
+ out.append(Receiver("react-native", ns, fm.group(1), nf, m.start(), fn="definition", cls=c[0] if c else None,
460
+ conf="exact", via="Expo " + fm.group(0).split("(")[0].strip(), api="expo-modules"))
461
+
462
+
463
+ def scan_flutter(nf: NativeFile, out: list, handler_classes: dict, pending_sites: list) -> None:
464
+ """MethodChannel / EventChannel registrations with their handler regions, and `call.method` sites."""
465
+ t = nf.t
466
+ regs = [] # (kind 'method'|'event', name, pos, region (lo, hi) or None, handler class name or None)
467
+ news = []
468
+ if nf.lang in ("kotlin", "java"):
469
+ for m in CHANNEL_NEW.finditer(t.code):
470
+ args = t.args(m.end() - 1)
471
+ if len(args) >= 2:
472
+ news.append(("event" if m.group(1) == "Event" else "method", args[1][1], m.start(), t.match(m.end() - 1, "(", ")")))
473
+ elif nf.lang == "swift":
474
+ for m in SWIFT_CHANNEL_NEW.finditer(t.code):
475
+ args = t.args(t.code.rfind("(", 0, m.end()))
476
+ if args:
477
+ news.append(("event" if m.group(1) == "Event" else "method", args[0][1], m.start(),
478
+ t.match(t.code.rfind("(", 0, m.end()), "(", ")")))
479
+ else:
480
+ for m in OBJC_CHANNEL_NEW.finditer(t.src):
481
+ news.append(("event" if m.group(1) == "Event" else "method", m.group(2), m.start(), t.match(m.start(), "[", "]")))
482
+ for kind, expr, pos, close in news:
483
+ name = _str_value(nf, expr, _GLOBAL_CONSTS)
484
+ if not name:
485
+ continue
486
+ setter = "setStreamHandler" if kind == "event" else "setMethodCallHandler"
487
+ region, hcls = None, None
488
+ # chained: MethodChannel(...).setMethodCallHandler(...) / held in a variable: x = MethodChannel(...); x.set...(...)
489
+ after = t.code[close + 1:close + 200]
490
+ sm = re.match(r"\s*\??\.\s*" + setter + r"\b", after)
491
+ hpos = close + 1 + sm.end() if sm else None
492
+ if hpos is None:
493
+ head = t.code[max(0, pos - 160):pos]
494
+ vm = re.search(r"(?:\b(?:val|var|let|final\s+\w+|\w+)\s+)?(?:self\.|this\.)?(\w+)\s*(?::\s*[\w.<>?]+\s*)?=\s*(?:new\s+)?$",
495
+ head)
496
+ if vm:
497
+ hm = re.compile(r"\b" + re.escape(vm.group(1)) + r"\s*[?!]?\s*\.\s*" + setter + r"\b").search(t.code, close)
498
+ if hm:
499
+ hpos = hm.end()
500
+ if hpos is not None:
501
+ rest = t.code[hpos:hpos + 400]
502
+ lm = re.match(r"\s*\(?\s*(\{)", rest) # trailing lambda / closure
503
+ cm = re.match(r"\s*\(\s*(?:new\s+)?(\w+)\s*(?:\(|\))", rest)
504
+ if lm:
505
+ lo = hpos + lm.start(1)
506
+ region = (lo, t.match(lo))
507
+ elif cm and cm.group(1) in ("this", "self"):
508
+ c = nf.class_at(pos)
509
+ region = (c[1], c[2]) if c else None
510
+ elif cm and cm.group(1)[:1].isupper():
511
+ hcls = cm.group(1)
512
+ c = nf.class_named(hcls)
513
+ region = (c[1], c[2]) if c else None
514
+ regs.append((kind, name, pos, region, hcls))
515
+ sites = []
516
+ for m in METHOD_EQ.finditer(t.src):
517
+ sites.append((next(g for g in m.groups() if g is not None), m.start()))
518
+ for m in SWITCH_ON.finditer(t.code):
519
+ end = t.match(m.end() - 1)
520
+ for cm in CASE_STR.finditer(t.src, m.end(), end):
521
+ for s in re.findall(r'"([^"]+)"', cm.group(1) or cm.group(2)):
522
+ sites.append((s, cm.start()))
523
+ mregs = [r for r in regs if r[0] == "method"]
524
+ for kind, name, pos, region, hcls in regs:
525
+ if hcls:
526
+ handler_classes.setdefault(hcls, []).append((kind, name, nf, pos))
527
+ if kind == "event":
528
+ # the stream handler: onListen of the handler class / region, else the registering function
529
+ if region:
530
+ lm = re.compile(r"\bonListen\b").search(t.code, region[0], region[1])
531
+ out.append(Receiver("flutter-event", name, None, nf, lm.start() if lm else pos, conf="resolved",
532
+ via="setStreamHandler"))
533
+ elif hcls:
534
+ pending_sites.append(("event", name, hcls, nf, pos))
535
+ else:
536
+ out.append(Receiver("flutter-event", name, None, nf, pos, conf="heuristic", via="EventChannel"))
537
+ for meth, spos in sites:
538
+ owners = [r for r in mregs if r[3] and r[3][0] <= spos <= r[3][1]]
539
+ if owners:
540
+ for r in owners[-1:]:
541
+ out.append(Receiver("flutter", r[1], meth, nf, spos, conf="resolved", via="setMethodCallHandler"))
542
+ continue
543
+ c = nf.class_at(spos)
544
+ if c:
545
+ pending_sites.append(("method", meth, c[0], nf, spos)) # a handler class registered elsewhere
546
+ if len(mregs) == 1 and not (c and any(r[4] == c[0] for r in mregs)):
547
+ out.append(Receiver("flutter", mregs[0][1], meth, nf, spos, conf="heuristic", via="call.method"))
548
+ elif len(mregs) > 1:
549
+ for r in mregs:
550
+ out.append(Receiver("flutter", r[1], meth, nf, spos, conf="heuristic", via="call.method"))
551
+
552
+
553
+ _GLOBAL_CONSTS: dict[str, str] = {}
554
+ # string-valued enum cases (#95): Swift `enum Event: String { case a, b = "x" }` -> `Event.a.rawValue`, Kotlin
555
+ # `enum class Events(val event: String) { SAVED("Saved") }` -> `Events.SAVED.event`
556
+ SWIFT_STR_ENUM = re.compile(r"\benum\s+(\w+)\s*:\s*String\b[^{]*\{")
557
+ KT_STR_ENUM = re.compile(r"\benum\s+class\s+(\w+)\s*\(\s*(?:(?:private|internal|public)\s+)?val\s+(\w+)\s*:\s*String\s*\)[^{]*\{")
558
+
559
+
560
+ def _enum_consts(rel: str, src: str) -> dict[str, str]:
561
+ out: dict[str, str] = {}
562
+ if rel.endswith(".swift") and "enum" in src:
563
+ for m in SWIFT_STR_ENUM.finditer(src):
564
+ depth, i = 1, m.end()
565
+ while i < len(src) and depth:
566
+ depth += {"{": 1, "}": -1}.get(src[i], 0)
567
+ i += 1
568
+ body = src[m.end():i - 1]
569
+ for cm in re.finditer(r"^\s*case\s+([^\n{(]+)$", body, re.M):
570
+ for part in cm.group(1).split(","):
571
+ pm = re.fullmatch(r'\s*(\w+)\s*(?:=\s*"([^"\\\n]*)")?\s*(?://.*)?', part)
572
+ if pm:
573
+ out[f"{m.group(1)}.{pm.group(1)}.rawValue"] = pm.group(2) if pm.group(2) is not None else pm.group(1)
574
+ elif rel.endswith(".kt") and "enum" in src:
575
+ for m in KT_STR_ENUM.finditer(src):
576
+ body = src[m.end():src.find("}", m.end())]
577
+ for em in re.finditer(r'\b([A-Z_][A-Z0-9_]*)\s*\(\s*"([^"\\\n]*)"\s*\)', body):
578
+ out[f"{m.group(1)}.{em.group(1)}.{m.group(2)}"] = em.group(2)
579
+ return out
580
+
581
+ # ------------------------------------------------------------------ Flutter MethodChannel: native -> Dart
582
+ NATIVE_INVOKE = re.compile(r"(?:\b(?:self|this)\s*\.\s*)?(\w+)\s*[?!]*\s*\.\s*invokeMethod\s*(?:<[^>]*>)?\s*\(\s*")
583
+ OBJC_INVOKE = re.compile(r"\[\s*(?:self\.|_)?(\w+)\s+invokeMethod:\s*")
584
+
585
+
586
+ def flutter_native_sends(builder, by_file, nfs: list) -> int:
587
+ """`channel.invokeMethod("m", args)` in Kotlin / Java / Swift / ObjC on a MethodChannel whose name is known
588
+ (the variable the channel was assigned to, or the file's only channel) -> SENDS_TO endpoint:flutter:<channel>#m
589
+ from the enclosing native method; the Dart `setMethodCallHandler` that tests `call.method == 'm'` receives it."""
590
+ n = 0
591
+ for nf in nfs:
592
+ t = nf.t
593
+ if "invokeMethod" not in t.code:
594
+ continue
595
+ chans = {} # variable -> channel name
596
+ names = []
597
+ if nf.lang in ("kotlin", "java"):
598
+ news = [(m.start(), t.args(m.end() - 1)) for m in CHANNEL_NEW.finditer(t.code) if m.group(1) != "Event"]
599
+ news = [(p, a[1][1]) for p, a in news if len(a) >= 2]
600
+ elif nf.lang == "swift":
601
+ news = []
602
+ for m in SWIFT_CHANNEL_NEW.finditer(t.code):
603
+ if m.group(1) == "Event":
604
+ continue
605
+ a = t.args(t.code.rfind("(", 0, m.end()))
606
+ if a:
607
+ news.append((m.start(), a[0][1]))
608
+ else:
609
+ news = [(m.start(), m.group(2)) for m in OBJC_CHANNEL_NEW.finditer(t.src) if m.group(1) != "Event"]
610
+ for pos, expr in news:
611
+ name = _str_value(nf, expr, _GLOBAL_CONSTS)
612
+ if not name:
613
+ continue
614
+ names.append(name)
615
+ head = t.code[max(0, pos - 160):pos]
616
+ vm = re.search(r"(?:self\.|this\.|_)?(\w+)\s*(?::\s*[\w.<>?]+\s*)?=\s*(?:new\s+)?(?:\[\s*)?$", head)
617
+ if vm:
618
+ chans.setdefault(vm.group(1), name)
619
+ if not names:
620
+ continue
621
+ rx = OBJC_INVOKE if nf.lang == "objc" else NATIVE_INVOKE
622
+ for m in rx.finditer(t.code):
623
+ var = m.group(1).lstrip("_")
624
+ name = chans.get(var) or chans.get(m.group(1)) or (names[0] if len(set(names)) == 1 else None)
625
+ if not name:
626
+ continue
627
+ sm = re.match(r'@?"([^"\n]+)"', t.src[m.end():m.end() + 200])
628
+ if not sm:
629
+ continue
630
+ conf = "resolved" if (var in chans or m.group(1) in chans) else "heuristic"
631
+ line = t.line(m.start())
632
+ hid = _handler(builder, by_file, Receiver("flutter", name, sm.group(1), nf, m.start()), nf, line)
633
+ if hid is None:
634
+ continue
635
+ plat, _why = _platform_of(nf.rel, nf.lang)
636
+ protocol_send(builder, "flutter", name, sm.group(1), hid, nf.rel, line, conf, via="invokeMethod (native)",
637
+ platform=plat, direction="to_app")
638
+ n += 1
639
+ return n
640
+
641
+
642
+ # ------------------------------------------------------------------ native -> JS events (#61)
643
+ RN_EMIT = re.compile(r"\.\s*emit\s*\(\s*")
644
+ RN_SEND_EVENT = re.compile(r"\bsendEvent\s*\(\s*(?:withName:\s*)?")
645
+ RN_EMIT_DEVICE = re.compile(r"\.\s*emitDeviceEvent\s*\(\s*")
646
+ JVM_FUN = re.compile(r"\bfun\s+(?:<[^>]*>\s*)?(\w+)\s*\(|\b(\w+)\s*\([^()]*\)\s*(?:throws\s+[\w.,\s]+)?\{")
647
+ OBJC_SEND_EVENT = re.compile(r"\[\s*(?:self|_?\w+)\s+sendEventWithName:\s*")
648
+ CAP_NOTIFY = re.compile(r"\bnotifyListeners\s*\(\s*")
649
+ OBJC_CAP_NOTIFY = re.compile(r"\[\s*(?:self|_?\w+)\s+notifyListeners:\s*")
650
+ RN_EMITTER_HINT = re.compile(r"RCTDeviceEventEmitter|DeviceEventManagerModule|RCTNativeAppEventEmitter|RCTEventEmitter|"
651
+ r"ModuleDefinition|ReactContext|ReactApplicationContext")
652
+
653
+
654
+ def _first_value(nf: NativeFile, text: str, after: int, args: bool) -> tuple[str | None, str]:
655
+ """The event name at `after` (the first call argument, or an ObjC message argument): (value, raw text)."""
656
+ t = nf.t
657
+ if args:
658
+ a = t.args(after - 1) if after > 0 and t.code[after - 1] == "(" else t.args(t.code.rfind("(", 0, after))
659
+ if not a:
660
+ return None, ""
661
+ parts = [x[1].strip() for x in a]
662
+ # sendEvent(reactContext, "evt", params): the first string-valued argument among the first two
663
+ for raw in parts[:2]:
664
+ v = _str_value(nf, raw.split(":", 1)[1] if re.match(r"^\w+:", raw) else raw, _GLOBAL_CONSTS)
665
+ if v:
666
+ return v, raw
667
+ return None, parts[0] if parts else ""
668
+ m = re.match(r'(@?"[^"\n]*"|[\w.]+)', t.src[after:after + 200])
669
+ if not m:
670
+ return None, ""
671
+ return _str_value(nf, m.group(1), _GLOBAL_CONSTS), m.group(1)
672
+
673
+
674
+ SWIFT_STR_PROP = re.compile(r"\bvar\s+(\w+)\s*:\s*String\s*\{")
675
+
676
+
677
+ def _str_props(nf: NativeFile) -> dict:
678
+ """Swift computed `var name: String { switch self { case .a: return "x" ... } }` whose every return is a string
679
+ literal -> name -> [values] (`event.listenerEvent` is one of them)."""
680
+ if nf.lang != "swift" or "String" not in nf.t.code:
681
+ return {}
682
+ out, t = {}, nf.t
683
+ for m in SWIFT_STR_PROP.finditer(t.code):
684
+ ob = m.end() - 1
685
+ body = t.src[ob:t.match(ob) + 1]
686
+ lits = re.findall(r'\breturn\s+"([^"\\\n]*)"', body) + re.findall(r'\bcase\s+[^:\n]+:\s*"([^"\\\n]*)"', body)
687
+ if lits and len(re.findall(r"\breturn\b", t.code[ob:t.match(ob) + 1])) == len(re.findall(r'\breturn\s+"', body)):
688
+ out[m.group(1)] = sorted(set(lits))
689
+ return out
690
+
691
+
692
+ def _event_values(nf: NativeFile, pos: int, raw: str, props: dict) -> list[str]:
693
+ """Event names a non-literal argument stands for: `self?.name` (a constant), a local `let event = self?.name` /
694
+ `val event = NAME` declared before the call, or `event.listenerEvent` (a Swift computed string property)."""
695
+ e = re.sub(r"[?!](?=\.)", "", (raw or "").strip())
696
+ if e.startswith("name:"):
697
+ e = e[5:].strip()
698
+ if not e:
699
+ return []
700
+ v = _str_value(nf, e, _GLOBAL_CONSTS)
701
+ if v:
702
+ return [v]
703
+ if re.fullmatch(r"[a-z]\w*", e):
704
+ src = nf.t.src[max(0, pos - 3000):pos]
705
+ last = None
706
+ for m in re.finditer(r"\b(?:let|var|val|final\s+String|String)\s+" + re.escape(e) +
707
+ r"\s*(?::\s*String\??)?\s*=\s*([^\n,;{]+)", src):
708
+ last = m
709
+ if last:
710
+ x = re.sub(r"[?!](?=\.)", "", re.split(r"\s+else\b", last.group(1))[0].strip())
711
+ v = _str_value(nf, x, _GLOBAL_CONSTS)
712
+ if v:
713
+ return [v]
714
+ if "." in x and x.split(".")[-1] in props:
715
+ return props[x.split(".")[-1]]
716
+ return []
717
+ if re.fullmatch(r"[\w.]+", e) and "." in e and e.split(".")[-1] in props:
718
+ return props[e.split(".")[-1]]
719
+ return []
720
+
721
+
722
+ def event_native_sends(builder, by_file, nfs: list, recs: list, dynamic: list, jvm_other: list = ()) -> dict:
723
+ """Native code emitting an event to JS -> SENDS_TO endpoint (direction to_app) from the enclosing method:
724
+ React Native `RCTDeviceEventEmitter.emit("evt", ...)`, `sendEvent(ctx, "evt", ...)`, ObjC `sendEventWithName:`,
725
+ Swift `sendEvent(withName:)`, Expo `sendEvent("evt", ...)`; Capacitor `notifyListeners("evt", data)` in a plugin
726
+ class (the plugin name from its @CapacitorPlugin / CAPBridgedPlugin receivers in the same file)."""
727
+ cap_ns = defaultdict(set)
728
+ for r in recs:
729
+ if r.protocol == "capacitor":
730
+ cap_ns[r.file.rel].add(r.namespace)
731
+ n = defaultdict(int)
732
+ helpers: dict = {} # Kotlin / Java helper forwarding its parameter as the event name: name -> (arg, via)
733
+ found_by: dict = {}
734
+ for nf in nfs:
735
+ t = nf.t
736
+ found = found_by.setdefault(nf.rel, []) # (protocol, namespace, method, pos, via)
737
+ props = _str_props(nf)
738
+ if "notifyListeners" in t.code:
739
+ ns = cap_ns.get(nf.rel)
740
+ rx = OBJC_CAP_NOTIFY if nf.lang == "objc" else CAP_NOTIFY
741
+ for m in rx.finditer(t.code):
742
+ v, raw = _first_value(nf, t.code, m.end(), nf.lang != "objc")
743
+ if not ns or len(ns) != 1:
744
+ continue
745
+ vals = [v] if v is not None else _event_values(nf, m.start(), raw, props)
746
+ if not vals:
747
+ dynamic.append({"file": nf.rel, "line": t.line(m.start()), "protocol": "capacitor-event",
748
+ "what": f"notifyListeners({raw[:40]})"})
749
+ continue
750
+ for v in vals:
751
+ found.append(("capacitor-event", next(iter(ns)), v, m.start(), "notifyListeners"))
752
+ swift_named = nf.lang == "swift" and re.search(r"\bsendEvent\s*\(\s*(?:withName|name)\s*:", t.code)
753
+ if RN_EMITTER_HINT.search(t.code) or swift_named:
754
+ sites = []
755
+ if nf.lang == "objc":
756
+ sites = [(m, False, "sendEventWithName") for m in OBJC_SEND_EVENT.finditer(t.code)]
757
+ else:
758
+ hint = bool(RN_EMITTER_HINT.search(t.code))
759
+ sites = [(m, True, "sendEvent") for m in RN_SEND_EVENT.finditer(t.code)
760
+ if hint or "withName" in m.group(0) or re.match(r"name\s*:", t.code[m.end():])]
761
+ if re.search(r"RCTDeviceEventEmitter|RCTNativeAppEventEmitter", t.code):
762
+ sites += [(m, True, "RCTDeviceEventEmitter.emit") for m in RN_EMIT.finditer(t.code)]
763
+ if nf.lang in ("kotlin", "java"):
764
+ sites += [(m, True, "emitDeviceEvent") for m in RN_EMIT_DEVICE.finditer(t.code)]
765
+ for m, is_args, via in sites:
766
+ # `fun sendEvent(name: String, ...)` / `private void sendEvent(...)`: the helper's own declaration
767
+ if re.search(r"\b(?:fun|void|func)\s+$", t.code[max(0, m.start() - 12):m.start() + 1]) or \
768
+ re.search(r"\b(?:fun|void|func)\s+sendEvent\s*\($", t.code[max(0, m.start() - 20):m.end()]):
769
+ continue
770
+ v, raw = _first_value(nf, t.code, m.end(), is_args)
771
+ if v is None and (vals := _event_values(nf, m.start(), raw, props)):
772
+ for v in vals:
773
+ found.append(("react-native-event", v, None, m.start(), via))
774
+ continue
775
+ if v is None:
776
+ h = _jvm_helper(nf, m.start(), raw) if nf.lang in ("kotlin", "java") else None
777
+ if h: # `fun sendJSEvent(eventName: String, ..)`
778
+ helpers.setdefault(h[0], (h[1], via))
779
+ continue
780
+ if raw and not re.match(r"^[a-z]\w*$", raw): # a parameter (`eventName`) is the helper itself
781
+ dynamic.append({"file": nf.rel, "line": t.line(m.start()), "protocol": "react-native-event",
782
+ "what": f"{via}({raw[:40]})"})
783
+ continue
784
+ found.append(("react-native-event", v, None, m.start(), via))
785
+ extra = []
786
+ if helpers and jvm_other: # Kotlin / Java files without bridge code calling a helper
787
+ hx = re.compile(r"(?<![\w$])(?:" + "|".join(map(re.escape, sorted(helpers))) + r")\s*\(")
788
+ for rel, path in jvm_other:
789
+ try:
790
+ src = path.read_text(encoding="utf-8", errors="replace")
791
+ except OSError:
792
+ continue
793
+ if hx.search(src):
794
+ extra.append(NativeFile(rel, src))
795
+ for nf in list(nfs) + extra: # calls of the helpers: `RNUtilsModuleImpl.sendJSEvent(Events.X.event, map)`
796
+ if not helpers or nf.lang not in ("kotlin", "java"):
797
+ continue
798
+ t = nf.t
799
+ for name, (idx, via) in helpers.items():
800
+ if name not in t.code:
801
+ continue
802
+ for m in re.finditer(r"(?<![\w$])" + re.escape(name) + r"\s*\(", t.code):
803
+ pre = t.code[t.code.rfind("\n", 0, m.start()) + 1:m.start()]
804
+ if re.search(r"\bfun\s+(?:<[^>]*>\s*)?(?:[\w.]+\.)?$", pre) or (
805
+ re.match(r"^\s*(?:@\w+\s+)*(?:(?:public|private|protected|static|final|synchronized)\s+)*[\w.<>\[\]]+\s+$", pre)
806
+ and not re.search(r"\b(?:return|else|throw|new)\s+$", pre)):
807
+ continue # the declaration (`fun sendJSEvent(` / `void sendJSEvent(`)
808
+ a = t.args(m.end() - 1)
809
+ if len(a) <= idx:
810
+ continue
811
+ raw = a[idx][1].strip()
812
+ v = _str_value(nf, raw, _GLOBAL_CONSTS)
813
+ if v is None:
814
+ if raw and not re.match(r"^[a-z]\w*$", raw) and not any(
815
+ d["file"] == nf.rel and d["line"] == t.line(m.start()) for d in dynamic):
816
+ dynamic.append({"file": nf.rel, "line": t.line(m.start()), "protocol": "react-native-event",
817
+ "what": f"{name}({raw[:40]})"})
818
+ continue
819
+ if any(f[3] == m.start() for f in found_by.get(nf.rel, ())):
820
+ continue # already a send site (`sendEvent(..)` itself)
821
+ found_by.setdefault(nf.rel, []).append(("react-native-event", v, None, m.start(), f"{name} -> {via}"))
822
+ for nf in list(nfs) + extra:
823
+ t = nf.t
824
+ for proto, ns, meth, pos, via in found_by.get(nf.rel, ()):
825
+ line = t.line(pos)
826
+ fn = None
827
+ if nf.lang == "objc": # inside RCT_EXPORT_METHOD(start:(NSString *)url) { ... }
828
+ for em in re.finditer(r"RCT_(?:EXPORT|REMAP)_METHOD\s*\(\s*(?:\w+\s*,\s*)?(\w+)", t.code[:pos]):
829
+ ob = t.code.find("{", em.end())
830
+ if 0 <= ob < pos <= t.match(ob):
831
+ fn = em.group(1)
832
+ hid = _handler(builder, by_file, Receiver(proto, ns, meth, nf, pos, fn=fn), nf, line)
833
+ if hid is None:
834
+ continue
835
+ plat, _why = _platform_of(nf.rel, nf.lang)
836
+ protocol_send(builder, proto, ns, meth, hid, nf.rel, line, "resolved", via=f"{via} (native)", platform=plat,
837
+ direction="to_app")
838
+ n[proto] += 1
839
+ return dict(n)
840
+
841
+
842
+ def _jvm_helper(nf: NativeFile, pos: int, raw: str):
843
+ """The Kotlin / Java function enclosing `pos` when `raw` is one of its parameters: (name, parameter index)."""
844
+ if not re.match(r"^[a-z]\w*$", raw or ""):
845
+ return None
846
+ t = nf.t
847
+ best = None
848
+ for m in JVM_FUN.finditer(t.code, 0, pos):
849
+ name = m.group(1) or m.group(2)
850
+ if name in ("if", "for", "while", "when", "switch", "catch", "synchronized", "return"):
851
+ continue
852
+ op = t.code.find("(", m.start())
853
+ cl = t.match(op, "(", ")")
854
+ ob = t.code.find("{", cl) if cl > 0 else -1
855
+ if ob < 0 or not (ob < pos <= t.match(ob)):
856
+ continue
857
+ best = (name, t.src[op + 1:cl])
858
+ if not best:
859
+ return None
860
+ for i, prm in enumerate(x for x in best[1].split(",")):
861
+ if re.match(r"^\s*(?:@\w+\s+)*(?:final\s+)?" + re.escape(raw) + r"\s*:", prm) or \
862
+ re.search(r"\bString\s+" + re.escape(raw) + r"\s*$", prm):
863
+ return best[0], i
864
+ return None
865
+
866
+
867
+ # ------------------------------------------------------------------ Cordova: native side (#61)
868
+ CORDOVA_JAVA_CLASS = re.compile(r"\bclass\s+(\w+)\s*(?:\([^)]*\))?\s*(?:extends|:)\s*(?:[\w.]+\.)?CordovaPlugin\b")
869
+ _CDV_V = r'("[^"]+"|[A-Z_][\w.]*)'
870
+ CORDOVA_ACTION = re.compile(r'\baction\s*\.\s*equals(?:IgnoreCase)?\s*\(\s*' + _CDV_V + r'\s*\)|' + _CDV_V +
871
+ r'\s*\.\s*equals(?:IgnoreCase)?\s*\(\s*action\s*\)|\baction\s*==\s*' + _CDV_V +
872
+ r'|^\s*case\s+' + _CDV_V + r'\s*:|^\s*' + _CDV_V + r'\s*->', re.M)
873
+ CORDOVA_OBJC_IFACE = re.compile(r"@interface\s+(\w+)\s*:\s*CDVPlugin\b")
874
+ CORDOVA_SWIFT_CLASS = re.compile(r"(?:@objc\s*\(\s*(\w+)\s*\)\s*)?(?:(?:public|open|final|internal)\s+)*class\s+(\w+)\s*:\s*CDVPlugin\b")
875
+ CORDOVA_OBJC_METHOD = re.compile(r"^\s*-\s*\(\s*void\s*\)\s*(\w+)\s*:\s*\(\s*CDVInvokedUrlCommand\s*\*\s*\)", re.M)
876
+ CORDOVA_SWIFT_METHOD = re.compile(r"(?:@objc\s*\(\s*(\w+):?\s*\)\s*)?(?:(?:public|open|internal|private|final|override|dynamic)\s+)*"
877
+ r"func\s+(\w+)\s*\(\s*_?\s*\w+\s*:\s*CDVInvokedUrlCommand")
878
+ FEATURE_RE = re.compile(r"<feature\s+name\s*=\s*\"([^\"]+)\"\s*>(.*?)</feature>", re.S)
879
+ FEATURE_PARAM = re.compile(r"<param\s+name\s*=\s*\"(android-package|ios-package)\"\s+value\s*=\s*\"([^\"]+)\"")
880
+
881
+
882
+ def cordova_services(root: Path) -> dict:
883
+ """plugin.xml / config.xml <feature name="Service"><param name="android-package" value="pkg.Cls"/> ->
884
+ {class simple name: service}; skips node_modules and platform build copies."""
885
+ out = {}
886
+ for dp, dns, fns in os.walk(root):
887
+ dns[:] = [d for d in dns if d not in ("node_modules", ".git", "build", "Pods") and not d.startswith(".")]
888
+ for fn in fns:
889
+ if fn not in ("plugin.xml", "config.xml"):
890
+ continue
891
+ try:
892
+ txt = (Path(dp) / fn).read_text(encoding="utf-8", errors="replace")
893
+ except OSError:
894
+ continue
895
+ for m in FEATURE_RE.finditer(txt):
896
+ for pm in FEATURE_PARAM.finditer(m.group(2)):
897
+ out.setdefault(pm.group(2).rsplit(".", 1)[-1], m.group(1))
898
+ return out
899
+
900
+
901
+ def scan_cordova(nf: NativeFile, out: list, services: dict) -> None:
902
+ t = nf.t
903
+ if nf.lang in ("java", "kotlin"):
904
+ for m in CORDOVA_JAVA_CLASS.finditer(t.code):
905
+ cls = m.group(1)
906
+ c = nf.class_named(cls)
907
+ lo, hi = (c[1], c[2]) if c else (m.start(), len(t.code))
908
+ ex = re.compile(r"\bexecute\s*\(").search(t.code, lo, hi)
909
+ if not ex:
910
+ continue
911
+ svc = services.get(cls, cls)
912
+ seen = set()
913
+ for am in CORDOVA_ACTION.finditer(t.src, ex.start(), hi):
914
+ act = _str_value(nf, next(g for g in am.groups() if g), _GLOBAL_CONSTS)
915
+ if not act or act in seen:
916
+ continue
917
+ seen.add(act)
918
+ out.append(Receiver("cordova", svc, act, nf, am.start(), fn="execute", cls=cls,
919
+ conf="resolved" if cls in services else "heuristic", via="CordovaPlugin.execute"))
920
+ elif nf.lang == "objc":
921
+ for m in CORDOVA_OBJC_IFACE.finditer(t.code):
922
+ cls = m.group(1)
923
+ svc = services.get(cls, cls)
924
+ for mm in CORDOVA_OBJC_METHOD.finditer(t.code):
925
+ c = nf.class_at(mm.start())
926
+ if c and c[0] != cls:
927
+ continue
928
+ out.append(Receiver("cordova", svc, mm.group(1), nf, mm.start(1), fn=mm.group(1), cls=cls,
929
+ conf="resolved" if cls in services else "heuristic", via="CDVPlugin method"))
930
+ if "@implementation" in t.code and not CORDOVA_OBJC_IFACE.search(t.code):
931
+ for im in OBJC_IMPL_RE.finditer(t.code): # the @interface is in the .h file
932
+ cls = im.group(1)
933
+ if cls not in services:
934
+ continue
935
+ for mm in CORDOVA_OBJC_METHOD.finditer(t.code, im.end()):
936
+ out.append(Receiver("cordova", services[cls], mm.group(1), nf, mm.start(1), fn=mm.group(1), cls=cls,
937
+ conf="resolved", via="CDVPlugin method"))
938
+ elif nf.lang == "swift":
939
+ for m in CORDOVA_SWIFT_CLASS.finditer(t.code):
940
+ objc_name, cls = m.group(1), m.group(2)
941
+ svc = services.get(objc_name or cls, services.get(cls, objc_name or cls))
942
+ end = t.match(t.code.find("{", m.end()))
943
+ for mm in CORDOVA_SWIFT_METHOD.finditer(t.code, m.end(), end):
944
+ out.append(Receiver("cordova", svc, mm.group(1) or mm.group(2), nf, mm.start(2), fn=mm.group(2), cls=cls,
945
+ conf="resolved" if (objc_name or cls) in services else "heuristic",
946
+ via="CDVPlugin method"))
947
+
948
+
949
+ # ------------------------------------------------------------------ Pigeon: native side
950
+ SWIFT_EXT = re.compile(r"\bextension\s+(\w+)\s*:\s*([^{]*)\{")
951
+ SUPER_NAME = re.compile(r"(?<![\w.])(?:\w+\.)*([A-Z]\w*)(\s*\()?")
952
+
953
+
954
+ def _decl_rx(lang: str, m: str):
955
+ if lang == "kotlin":
956
+ return re.compile(r"\bfun\s+(?:<[^>]*>\s*)?" + re.escape(m) + r"\s*\(")
957
+ if lang == "swift":
958
+ return re.compile(r"\bfunc\s+" + re.escape(m) + r"\s*[(<]")
959
+ if lang == "java":
960
+ return re.compile(r"[\w<>\[\],.?]+\s+" + re.escape(m) + r"\s*\([^;{)]*\)\s*(?:throws\s+[\w., ]+)?\{")
961
+ return re.compile(r"^\s*-\s*\([^)]*\)\s*" + re.escape(m) + r"\b", re.M)
962
+
963
+
964
+ def _supers(nf: NativeFile, start: int) -> list[tuple[str, bool]]:
965
+ """Supertype names of the class / extension declared at `start`: (name, called-as-constructor)."""
966
+ code = nf.t.code
967
+ head = code[start:code.find("{", start)]
968
+ if nf.lang == "java":
969
+ m = re.search(r"\b(?:extends|implements)\b(.*)", head, re.S)
970
+ tail = m.group(1) if m else ""
971
+ else:
972
+ i, depth = -1, 0 # the ':' outside a Kotlin primary constructor `class X(ctx: Context) : Base(ctx), Api`
973
+ for j, ch in enumerate(head):
974
+ if ch in "(<":
975
+ depth += 1
976
+ elif ch in ")>":
977
+ depth -= 1
978
+ elif ch == ":" and depth == 0:
979
+ i = j
980
+ break
981
+ tail = head[i + 1:] if i >= 0 else ""
982
+ tail = re.sub(r"<[^<>]*>", "", tail)
983
+ tail = re.sub(r"\bwhere\b.*", "", tail, flags=re.S)
984
+ return [(m.group(1), bool(m.group(2))) for m in SUPER_NAME.finditer(tail)]
985
+
986
+
987
+ def scan_pigeon_hosts(nfs: list, apis: dict, out: list) -> None:
988
+ """@HostApi implementations: a Kotlin / Swift / Java class (or Swift extension) that implements the generated
989
+ interface / protocol `Api`; each API method is looked up in the class, then in its superclasses (an
990
+ `ApiImplBase` holding the shared methods)."""
991
+ host = {k: v for k, v in apis.items() if v["kind"] == "host"}
992
+ if not host:
993
+ return
994
+ decls = defaultdict(list) # class name -> [(nf, start, end)]
995
+ for nf in nfs:
996
+ for name, lo, hi in nf.classes:
997
+ decls[name].append((nf, lo, hi))
998
+ if nf.lang == "swift":
999
+ for m in SWIFT_EXT.finditer(nf.t.code):
1000
+ decls[m.group(1)].append((nf, m.start(), nf.t.match(m.end() - 1)))
1001
+
1002
+ def find(cls, meth, lang_fam, depth=0):
1003
+ for nf, lo, hi in decls.get(cls, ()):
1004
+ if FAM[nf.lang] != lang_fam:
1005
+ continue
1006
+ hit = _decl_rx(nf.lang, meth).search(nf.t.code, lo, hi)
1007
+ if hit:
1008
+ return nf, hit.start(), cls
1009
+ if depth < 3:
1010
+ for nf, lo, _hi in decls.get(cls, ()):
1011
+ for sup, _call in _supers(nf, lo):
1012
+ if sup != cls and sup not in host:
1013
+ got = find(sup, meth, lang_fam, depth + 1)
1014
+ if got:
1015
+ return got
1016
+ return None
1017
+
1018
+ seen = set()
1019
+ for cls, items in decls.items():
1020
+ for nf, lo, _hi in items:
1021
+ for sup, called in _supers(nf, lo):
1022
+ if sup not in host or called:
1023
+ continue
1024
+ for meth in host[sup]["methods"]:
1025
+ got = find(cls, meth, FAM[nf.lang])
1026
+ if not got:
1027
+ continue
1028
+ rnf, pos, rcls = got
1029
+ key = (sup, meth, rnf.rel, pos)
1030
+ if key in seen:
1031
+ continue
1032
+ seen.add(key)
1033
+ out.append(Receiver("pigeon", sup, meth, rnf, pos, fn=meth, cls=rcls, conf="resolved",
1034
+ via=f"Pigeon HostApi ({cls} : {sup})"))
1035
+
1036
+
1037
+ FAM = {"kotlin": "jvm", "java": "jvm", "swift": "apple", "objc": "apple"}
1038
+
1039
+
1040
+ def pigeon_native_sends(builder, by_file, nfs: list, apis: dict) -> int:
1041
+ """@FlutterApi calls from native code: a variable / property holding the generated class
1042
+ (`flutterApi = BackgroundWorkerFlutterApi(binaryMessenger)`, `var api: XFlutterApi?`) and its method calls
1043
+ (`flutterApi?.onAndroidUpload(...)`) -> SENDS_TO endpoint:pigeon:<Api>#<method> from the enclosing native method."""
1044
+ flutter = {k: v for k, v in apis.items() if v["kind"] == "flutter"}
1045
+ n = 0
1046
+ for nf in nfs:
1047
+ code = nf.t.code
1048
+ for api, info in flutter.items():
1049
+ if api not in code:
1050
+ continue
1051
+ vars_ = set()
1052
+ for m in re.finditer(r"(\w+)\s*:\s*(?:[\w.]+\.)?" + re.escape(api) + r"\b\s*[?!]?", code):
1053
+ vars_.add(m.group(1))
1054
+ for m in re.finditer(r"(\w+)\s*=\s*(?:new\s+)?(?:[\w.]+\.)?" + re.escape(api) + r"\s*\(", code):
1055
+ vars_.add(m.group(1))
1056
+ meths = "|".join(map(re.escape, info["methods"]))
1057
+ if not meths:
1058
+ continue
1059
+ pats = []
1060
+ if vars_:
1061
+ pats.append(re.compile(r"(?<![\w])(?:(?:self|this)\s*\.\s*)?(?:" + "|".join(map(re.escape, sorted(vars_)))
1062
+ + r")\s*[?!]*\s*\.\s*(" + meths + r")\s*[({]"))
1063
+ pats.append(re.compile(re.escape(api) + r"\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*[?!]*\s*\.\s*(" + meths + r")\s*[({]"))
1064
+ for rx in pats:
1065
+ for m in rx.finditer(code):
1066
+ line = nf.t.line(m.start(1))
1067
+ hid = _handler(builder, by_file, Receiver("pigeon", api, m.group(1), nf, m.start(1)), nf, line)
1068
+ if hid is None:
1069
+ continue
1070
+ plat, _why = _platform_of(nf.rel, nf.lang)
1071
+ protocol_send(builder, "pigeon", api, m.group(1), hid, nf.rel, line, "resolved",
1072
+ via="Pigeon FlutterApi call", platform=plat, direction="to_app")
1073
+ n += 1
1074
+ return n
1075
+
1076
+
1077
+ # ------------------------------------------------------------------ the index pass
1078
+ def _platform_of(rel: str, lang: str) -> tuple[str, str]:
1079
+ parts = [p.lower() for p in rel.split("/")[:-1]]
1080
+ for p in ("android", "ios", "macos"):
1081
+ if p in parts:
1082
+ return p, f"{p}/ directory"
1083
+ for p in parts:
1084
+ if p in ("androidmain", "androidtest"):
1085
+ return "android", f"{p}/ source set"
1086
+ if p in ("iosmain",):
1087
+ return "ios", f"{p}/ source set"
1088
+ if lang in ("kotlin", "java"):
1089
+ return "android", f"{lang} native module"
1090
+ return "ios", f"{lang} native module"
1091
+
1092
+
1093
+ def _native_files(project, scanned, builder) -> list[str]:
1094
+ out = []
1095
+ if scanned is not None:
1096
+ for e in (".kt", ".swift"):
1097
+ out += scanned.paths.get(e, [])
1098
+ out += getattr(scanned, "bridge_paths", [])
1099
+ return sorted(set(out))
1100
+
1101
+
1102
+ TAURI_CMD = re.compile(r"#\[\s*(tauri::)?command\b[^\]]*\]\s*(?:#\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?"
1103
+ r"(?:async\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)")
1104
+ TAURI_HANDLER = re.compile(r"generate_handler!\s*\[((?:[^\[\]]|\[[^\]]*\])*)\]") # `#![plugin(x)]` inside
1105
+ TAURI_PLUGIN = re.compile(r"\bBuilder\s*(?:::\s*<[^>]*>\s*)?::\s*new\s*\(\s*\"([\w-]+)\"")
1106
+
1107
+
1108
+ def _common_dirs(a: str, b: str) -> int:
1109
+ pa, pb = a.split("/")[:-1], b.split("/")[:-1]
1110
+ n = 0
1111
+ for x, y in zip(pa, pb):
1112
+ if x != y:
1113
+ break
1114
+ n += 1
1115
+ return n
1116
+
1117
+
1118
+ def tauri_receivers(project, builder) -> dict:
1119
+ """Tauri commands: `#[tauri::command] fn cmd` in the Rust core -> RECEIVED_BY from endpoint:tauri:<cmd> (a command
1120
+ registered by a plugin crate, `tauri::plugin::Builder::new("x")...generate_handler![cmd]`, is
1121
+ endpoint:tauri:plugin:x|cmd). attrs.registered: listed in a generate_handler! (an unregistered command cannot be
1122
+ invoked)."""
1123
+ root = Path(project.root)
1124
+ by_file = defaultdict(dict)
1125
+ for n in builder.nodes.values():
1126
+ if n.lang == "rust" and n.kind in ("function", "method") and n.file:
1127
+ by_file[n.file].setdefault(n.name.rsplit("::", 1)[-1], n.id)
1128
+ cmds, registered = [], {}
1129
+ for rel in sorted(by_file):
1130
+ try:
1131
+ src = (root / rel).read_text(encoding="utf-8", errors="replace")
1132
+ except OSError:
1133
+ continue
1134
+ if "command" not in src and "generate_handler" not in src:
1135
+ continue
1136
+ src = strip_comments(src)
1137
+ bare_ok = bool(re.search(r"\buse\s+tauri::(?:\{[^}]*\bcommand\b|command\b)", src))
1138
+ for m in TAURI_CMD.finditer(src):
1139
+ if m.group(1) or bare_ok:
1140
+ cmds.append((rel, m.group(2), src.count("\n", 0, m.start(2)) + 1))
1141
+ plugin = TAURI_PLUGIN.search(src)
1142
+ for h in TAURI_HANDLER.finditer(src):
1143
+ for item in re.sub(r"#!?\[[^\]]*\]", " ", h.group(1)).split(","):
1144
+ name = item.strip().rsplit("::", 1)[-1].strip()
1145
+ if re.fullmatch(r"[A-Za-z_]\w*", name):
1146
+ registered.setdefault(name, []).append((rel, plugin.group(1) if plugin else None))
1147
+ n = 0
1148
+ for rel, name, line in cmds:
1149
+ nid = by_file[rel].get(name)
1150
+ if not nid:
1151
+ continue
1152
+ # the same command name registered by several plugins (Tauri's own `window` / `webview` / `menu` plugins all
1153
+ # have `new`, `set_icon`, ...): the registration closest to the command's file (longest common directory)
1154
+ regs = registered.get(name) or []
1155
+ plugin = max(regs, key=lambda r: (_common_dirs(r[0], rel), r[0] == rel))[1] if regs else None
1156
+ ns = f"plugin:{plugin}|{name}" if plugin else name
1157
+ ep = protocol_receive(builder, "tauri", ns, None, nid, rel, line, "exact", via="#[tauri::command]",
1158
+ process="core", registered=name in registered)
1159
+ if name not in registered:
1160
+ builder.nodes[ep].attrs["unregistered"] = True
1161
+ n += 1
1162
+ return {"commands": n, "registered": len(registered)} if n else {}
1163
+
1164
+
1165
+ def apply(project, builder, scanned=None, sends_only: bool = False) -> dict:
1166
+ """Index pass: native receivers of every bridge protocol -> RECEIVED_BY edges (+ stub nodes for Java / ObjC) and
1167
+ platform marks on their files. Returns the bridges stats (empty when there is no bridge code)."""
1168
+ from .platforms import BIG, Cond, _plat_atom, mark
1169
+ tauri = tauri_receivers(project, builder)
1170
+ st0 = {"tauri": tauri} if tauri else {}
1171
+ st = _apply(project, builder, scanned, sends_only, mark, BIG, Cond, _plat_atom)
1172
+ if st0 or st:
1173
+ st = {**(st or {}), **st0}
1174
+ return st or {}
1175
+
1176
+
1177
+ def _apply(project, builder, scanned, sends_only, mark, BIG, Cond, _plat_atom) -> dict:
1178
+ sends = [e for e in builder.edges.values() if e.kind == "SENDS_TO" or (e.kind == "TEST_CALLS" and e.attrs.get("orig") == "SENDS_TO")]
1179
+ root = Path(project.root)
1180
+ files = _native_files(project, scanned, builder)
1181
+ if not sends and not files:
1182
+ return {}
1183
+ clf = project.options.get("generated")
1184
+ apis = getattr(builder, "pigeon_apis", None) or {}
1185
+ # an API name or a name starting with it (`NativeSyncApiImplBase` holds methods its subclasses inherit)
1186
+ pigeon_rx = re.compile(r"\b(?:" + "|".join(map(re.escape, sorted(apis))) + r")") if apis else None
1187
+ nfs: list[NativeFile] = []
1188
+ other_consts: dict[str, str] = {}
1189
+ jvm_other: list = []
1190
+ _GLOBAL_CONSTS.clear()
1191
+ for rel in files:
1192
+ if clf is not None and clf.excludes(rel):
1193
+ continue
1194
+ try:
1195
+ src = (root / rel).read_text(encoding="utf-8", errors="replace")
1196
+ except OSError:
1197
+ continue
1198
+ if len(src) > 2_000_000:
1199
+ continue
1200
+ for k, v in _enum_consts(rel, src).items():
1201
+ other_consts.setdefault(k, v)
1202
+ if not (TRIGGER.search(src) or (pigeon_rx and pigeon_rx.search(src))):
1203
+ if rel.endswith((".kt", ".java")):
1204
+ jvm_other.append((rel, root / rel)) # may call an event helper of a scanned file
1205
+ # not scanned, but its class constants name modules elsewhere (`getName() = FooImpl.NAME`): qualified only
1206
+ if "String" in src and (cm := CLASS_RE.search(src)):
1207
+ for m in CONST_RE.finditer(src):
1208
+ other_consts.setdefault(f"{cm.group(1)}.{m.group(1)}", m.group(2))
1209
+ continue
1210
+ nf = NativeFile(rel, src)
1211
+ nfs.append(nf)
1212
+ for nf in nfs: # NAME constants of other classes (getName() = FooImpl.NAME, channel names in a Constants file)
1213
+ cls = nf.classes[0][0] if nf.classes else None
1214
+ for k, v in nf.consts.items():
1215
+ _GLOBAL_CONSTS.setdefault(k, v)
1216
+ if cls:
1217
+ _GLOBAL_CONSTS.setdefault(f"{cls}.{k}", v)
1218
+ for k, v in other_consts.items():
1219
+ _GLOBAL_CONSTS.setdefault(k, v)
1220
+ if not nfs and not sends:
1221
+ return {}
1222
+ # TurboModule codegen spec classes: NativeFooSpec <- the JS spec file NativeFoo.ts naming the module
1223
+ spec_names = {}
1224
+ for e in sends:
1225
+ at = e.attrs.get("module_at")
1226
+ if at and e.attrs.get("via") and "TurboModuleRegistry" in e.attrs["via"]:
1227
+ stem = os.path.splitext(at.split(":")[0].rsplit("/", 1)[-1])[0]
1228
+ nid = e.dst
1229
+ spec_names.setdefault(stem + "Spec", nid.split(":", 2)[2].split("#")[0])
1230
+ objc_plugins, extern, handler_classes, pending = {}, {}, {}, []
1231
+ for nf in nfs:
1232
+ if nf.lang == "objc":
1233
+ scan_objc_capacitor(nf, objc_plugins)
1234
+ recs: list[Receiver] = []
1235
+ cdv = cordova_services(root) if any(re.search(r"CordovaPlugin|CDVPlugin", nf.t.code) for nf in nfs) else {}
1236
+ for nf in nfs:
1237
+ scan_cordova(nf, recs, cdv)
1238
+ scan_capacitor(nf, recs, objc_plugins)
1239
+ scan_react_native(nf, recs, spec_names, extern)
1240
+ scan_expo(nf, recs)
1241
+ scan_flutter(nf, recs, handler_classes, pending)
1242
+ # CAP_PLUGIN / RCT_EXTERN_MODULE registrations whose Swift class was not found: ObjC stubs at the macro
1243
+ swift_cls = {}
1244
+ for nf in nfs:
1245
+ if nf.lang == "swift":
1246
+ for m in SWIFT_CLASS.finditer(nf.t.code):
1247
+ end = nf.t.match(m.end() - 1)
1248
+ funcs = {f.group(1): f.start(1) for f in SWIFT_FUNC.finditer(nf.t.code, m.start(), end)}
1249
+ for nm in {m.group(1), m.group(2)} - {None}:
1250
+ swift_cls.setdefault(nm, (nf, m.group(2), funcs))
1251
+ have = {(r.protocol, r.namespace, r.method, r.file.rel) for r in recs}
1252
+ for cls, (js, methods, nf, pos) in objc_plugins.items():
1253
+ sw = swift_cls.get(cls)
1254
+ for meth in methods:
1255
+ if sw and meth in sw[2]:
1256
+ if ("capacitor", js, meth, sw[0].rel) not in have:
1257
+ recs.append(Receiver("capacitor", js, meth, sw[0], sw[2][meth], fn=meth, cls=sw[1], conf="exact",
1258
+ via="CAP_PLUGIN_METHOD"))
1259
+ else:
1260
+ recs.append(Receiver("capacitor", js, meth, nf, pos, fn=meth, cls=cls, conf="resolved", via="CAP_PLUGIN_METHOD"))
1261
+ for cls, items in extern.items():
1262
+ sw = swift_cls.get(cls)
1263
+ for ns, meth, nf, pos in items:
1264
+ node = None if sw and meth in sw[2] else builder.nodes.get(f"method:{cls}.{meth}")
1265
+ if node is not None and node.file:
1266
+ recs.append(Receiver("react-native", ns, meth, NativeFile(node.file, ""), 0, fn=meth, cls=cls, conf="exact",
1267
+ via="RCT_EXTERN_METHOD", lookup=(node.id, node.line)))
1268
+ elif sw and meth in sw[2]:
1269
+ recs.append(Receiver("react-native", ns, meth, sw[0], sw[2][meth], fn=meth, cls=sw[1], conf="exact",
1270
+ via="RCT_EXTERN_METHOD"))
1271
+ else:
1272
+ recs.append(Receiver("react-native", ns, meth, nf, pos, fn=meth, cls=cls, conf="resolved", via="RCT_EXTERN_METHOD"))
1273
+ # method sites / stream handlers in handler classes registered from another place
1274
+ fam = {"kotlin": "jvm", "java": "jvm", "swift": "apple", "objc": "apple"}
1275
+ for kind, meth, cls, nf, spos in pending:
1276
+ for hk, name, rnf, rpos in handler_classes.get(cls, ()):
1277
+ if kind == "method" and hk == "method" and fam[nf.lang] == fam[rnf.lang]:
1278
+ recs.append(Receiver("flutter", name, meth, nf, spos, conf="resolved", via="setMethodCallHandler(handler class)"))
1279
+ for cls, regs in handler_classes.items():
1280
+ for hk, name, rnf, rpos in regs:
1281
+ if hk != "event":
1282
+ continue
1283
+ target = next(((nf, c) for nf in nfs for c in nf.classes if c[0] == cls and fam[nf.lang] == fam[rnf.lang]), None)
1284
+ if target:
1285
+ nf, c = target
1286
+ lm = re.compile(r"\bonListen\b").search(nf.t.code, c[1], c[2])
1287
+ recs.append(Receiver("flutter-event", name, None, nf, lm.start() if lm else c[1], conf="resolved",
1288
+ via="setStreamHandler(handler class)"))
1289
+ else:
1290
+ recs.append(Receiver("flutter-event", name, None, rnf, rpos, conf="heuristic", via="setStreamHandler"))
1291
+ if apis:
1292
+ scan_pigeon_hosts(nfs, apis, recs)
1293
+ # receivers -> handler nodes
1294
+ by_file = defaultdict(list)
1295
+ for n in builder.nodes.values():
1296
+ if n.file and n.kind in ("method", "function") and n.line:
1297
+ by_file[n.file].append(n)
1298
+ pigeon_sends = pigeon_native_sends(builder, by_file, nfs, apis) if apis else 0
1299
+ flutter_sends = flutter_native_sends(builder, by_file, nfs)
1300
+ dynamic = list(getattr(builder, "bridge_dynamic", None) or [])
1301
+ event_sends = event_native_sends(builder, by_file, nfs, recs, dynamic, jvm_other)
1302
+ n_recv, n_stub, marked = 0, 0, set()
1303
+ stats = defaultdict(lambda: defaultdict(int))
1304
+ seen = set()
1305
+ for r in recs:
1306
+ nf = r.file
1307
+ if r.lookup: # resolved to a language plugin node directly
1308
+ hid, line = r.lookup
1309
+ else:
1310
+ line = nf.t.line(r.pos)
1311
+ hid = _handler(builder, by_file, r, nf, line)
1312
+ if hid is None:
1313
+ continue
1314
+ if builder.nodes[hid].attrs.get("bridge_stub"):
1315
+ n_stub += 1
1316
+ plat, why = _platform_of(nf.rel, nf.lang)
1317
+ key = (r.protocol, r.namespace, r.method, hid)
1318
+ if key in seen:
1319
+ continue
1320
+ seen.add(key)
1321
+ protocol_receive(builder, r.protocol, r.namespace, r.method, hid, nf.rel, line, r.conf, platform=plat, via=r.via,
1322
+ api=r.api)
1323
+ if r.api:
1324
+ builder.nodes["endpoint:" + endpoint_key(r.protocol, r.namespace, r.method)].attrs["api"] = r.api
1325
+ stats[r.protocol]["receivers"] += 1
1326
+ n_recv += 1
1327
+ if nf.rel not in marked:
1328
+ marked.add(nf.rel)
1329
+ mark(builder, nf.rel, 1, BIG, Cond("tree", _plat_atom(plat), why), line=1)
1330
+ builder.platform_marks[-1]["bridge"] = True
1331
+ for e in sends:
1332
+ stats[e.attrs.get("protocol") or e.dst.split(":", 2)[1]]["sends" if e.kind == "SENDS_TO" else "test_sends"] += 1
1333
+ if pigeon_sends:
1334
+ stats["pigeon"]["native_sends"] += pigeon_sends
1335
+ if flutter_sends:
1336
+ stats["flutter"]["native_sends"] += flutter_sends
1337
+ for proto, k in event_sends.items():
1338
+ stats[proto]["native_sends"] += k
1339
+ out = {"native_files_scanned": len(nfs), "receivers": n_recv, "stub_nodes": n_stub,
1340
+ "per_protocol": {k: dict(v) for k, v in sorted(stats.items())}}
1341
+ if dynamic:
1342
+ out["dynamic"] = {"count": len(dynamic), "samples": dynamic[:20]}
1343
+ return out
1344
+
1345
+
1346
+ _DECL_RX = {
1347
+ "java": re.compile(r"(?:(?:public|private|protected|static|final|synchronized|native|abstract)\s+)+[\w<>\[\],.?]+\s+(\w+)\s*"
1348
+ r"\([^;{)]*\)\s*(?:throws\s+[\w., ]+)?\{"),
1349
+ "objc": re.compile(r"^\s*[-+]\s*\([^)]*\)\s*(\w+)[^{;]*\{", re.M),
1350
+ "swift": re.compile(r"\bfunc\s+(\w+)\s*(?:<[^>]*>)?\s*\([^{]*\{"),
1351
+ "kotlin": re.compile(r"\bfun\s+(?:<[^>]*>\s*)?(?:[\w.]+\.)?(\w+)\s*\([^{=]*\{"),
1352
+ }
1353
+
1354
+
1355
+ def _enclosing_decl(nf: NativeFile, pos: int) -> tuple[str, int, int] | None:
1356
+ """Enclosing Java / ObjC method (name, start, end) for receivers in files without a language plugin."""
1357
+ t = nf.t
1358
+ rx = _DECL_RX[nf.lang]
1359
+ best = None
1360
+ for m in rx.finditer(t.code, 0, pos + 1):
1361
+ end = t.match(m.end() - 1)
1362
+ if m.start() <= pos <= end or m.start(1) <= pos <= m.end(1):
1363
+ best = (m.group(1), m.start(), end)
1364
+ return best
1365
+
1366
+
1367
+ def _handler(builder, by_file, r: Receiver, nf: NativeFile, line: int) -> str | None:
1368
+ cands = by_file.get(nf.rel, [])
1369
+ if r.fn:
1370
+ hit = [n for n in cands if n.name == r.fn and n.line <= line <= (n.end_line or n.line)]
1371
+ hit = hit or [n for n in cands if n.name == r.fn and abs(n.line - line) <= 3]
1372
+ if hit:
1373
+ return min(hit, key=lambda n: (n.end_line or n.line) - n.line).id
1374
+ else:
1375
+ hit = [n for n in cands if n.line <= line <= (n.end_line or n.line)]
1376
+ if hit:
1377
+ return min(hit, key=lambda n: (n.end_line or n.line) - n.line).id
1378
+ # no language plugin node (Java, ObjC, or the Kotlin / Swift plugin did not run): a stub method node
1379
+ fn, lo, hi = r.fn, r.pos, r.pos
1380
+ d = _enclosing_decl(nf, r.pos)
1381
+ if d and (not r.fn or d[0] == r.fn or nf.lang == "java"):
1382
+ fn, lo, hi = r.fn or d[0], d[1], d[2]
1383
+ if not fn:
1384
+ return None
1385
+ c = nf.class_at(r.pos)
1386
+ cls = r.cls or (c[0] if c else None)
1387
+ if nf.lang == "objc":
1388
+ fqn = f"objc:{cls}.{fn}" if cls else f"objc:{nf.rel}#{fn}"
1389
+ elif nf.lang == "swift":
1390
+ fqn = f"{cls}.{fn}" if cls else f"{nf.rel}#{fn}"
1391
+ else:
1392
+ fqn = ".".join(x for x in (nf.package, cls, fn) if x)
1393
+ nid = builder.add_node("method", fqn, fn, fqn=fqn, file=nf.rel, line=nf.t.line(lo), end_line=nf.t.line(hi), lang=nf.lang,
1394
+ attrs={"bridge_stub": True})
1395
+ by_file[nf.rel].append(builder.nodes[nid])
1396
+ return nid
1397
+
1398
+
1399
+ def project_targets(project, builder) -> list[str]:
1400
+ """Targets of a project without platform-specific code (platforms.apply returned nothing)."""
1401
+ from .platforms import declared_targets
1402
+ langs = {n.lang for n in builder.nodes.values() if n.lang}
1403
+ try:
1404
+ return declared_targets(Path(project.root), project.options.get("config") or {}, [], langs)[0]
1405
+ except Exception: # noqa: BLE001
1406
+ return []
1407
+
1408
+
1409
+ def _process_roles(builder, eps, recv, send) -> None:
1410
+ """Electron / Tauri: process role of each file taking part in IPC (attrs.process on its module node): main,
1411
+ preload (exposes a context bridge), renderer (Electron), webview / core (Tauri)."""
1412
+ roles = defaultdict(set)
1413
+ for nid, n in eps.items():
1414
+ if n.attrs.get("protocol") not in PROCESS_PROTOCOLS:
1415
+ continue
1416
+ for e in recv[nid]:
1417
+ dn = builder.nodes.get(e.dst)
1418
+ if dn and dn.file and e.attrs.get("process"):
1419
+ roles[dn.file].add(e.attrs["process"])
1420
+ for e in send[nid]:
1421
+ sn = builder.nodes.get(e.src)
1422
+ if sn and sn.file and e.attrs.get("process"):
1423
+ roles[sn.file].add(e.attrs["process"])
1424
+ if not roles:
1425
+ return
1426
+ for n in builder.nodes.values():
1427
+ if n.kind in ("module", "file", "mod", "crate") and n.file in roles:
1428
+ r = roles[n.file]
1429
+ n.attrs["process"] = "preload" if "preload" in r else next(iter(r)) if len(r) == 1 else "+".join(sorted(r))
1430
+
1431
+
1432
+ def finalize(project, builder, st: dict, targets: list[str] | None) -> dict:
1433
+ """Per endpoint: platforms receiving it, sides seen, checks. Called after platforms.apply (targets known)."""
1434
+ eps = {nid: n for nid, n in builder.nodes.items() if n.kind == "endpoint" and n.attrs.get("protocol") in PROTOCOLS}
1435
+ if not eps:
1436
+ return st
1437
+ recv = defaultdict(list)
1438
+ send = defaultdict(list)
1439
+ tsend = defaultdict(list)
1440
+ for e in builder.edges.values():
1441
+ if e.kind == "RECEIVED_BY" and e.src in eps:
1442
+ recv[e.src].append(e)
1443
+ elif e.kind == "SENDS_TO" and e.dst in eps:
1444
+ send[e.dst].append(e)
1445
+ elif e.kind == "TEST_CALLS" and e.attrs.get("orig") == "SENDS_TO" and e.dst in eps:
1446
+ tsend[e.dst].append(e)
1447
+ ns_plat = defaultdict(set) # (protocol, namespace) -> platforms implementing any method of it
1448
+ for nid, es in recv.items():
1449
+ a = eps[nid].attrs
1450
+ for e in es:
1451
+ ns_plat[(a["protocol"], a["namespace"])].add(e.attrs.get("platform"))
1452
+ ev_sent = defaultdict(set) # event protocol -> namespaces some native code emits on (None: any)
1453
+ for nid, es in send.items():
1454
+ a = eps[nid].attrs
1455
+ if a["protocol"] in EVENT_PROTOCOLS and es:
1456
+ ev_sent[a["protocol"]].update((None, a["namespace"]))
1457
+ proto_recv = defaultdict(set)
1458
+ for nid in recv:
1459
+ proto_recv[eps[nid].attrs["protocol"]].add(None)
1460
+ _process_roles(builder, eps, recv, send)
1461
+ # macOS counts only where some bridge module is implemented for it (a Flutter macos/ runner)
1462
+ used = {e.attrs.get("platform") for es in recv.values() for e in es}
1463
+ mobile = [p for p in (targets or []) if p in ("android", "ios") or (p == "macos" and p in used)]
1464
+ checks = defaultdict(int)
1465
+ root = Path(project.root)
1466
+ pj_cache: dict = {}
1467
+ for nid, n in eps.items():
1468
+ a = n.attrs
1469
+ rp = sorted({e.attrs.get("platform") for e in recv[nid]} - {None})
1470
+ a["platforms_received"] = rp
1471
+ a["side"] = "both" if send[nid] and recv[nid] else "send" if send[nid] or tsend[nid] else "receive"
1472
+ # native -> app (Pigeon @FlutterApi, a native invokeMethod handled in Dart): the app side receives on every
1473
+ # platform; the platforms are the senders'
1474
+ to_app = any(e.attrs.get("direction") == "to_app" for e in send[nid]) or (
1475
+ recv[nid] and all((builder.nodes.get(e.dst) is not None and builder.nodes[e.dst].lang in APP_LANGS)
1476
+ for e in recv[nid]))
1477
+ if to_app:
1478
+ a["direction"] = "to_app"
1479
+ a["platforms_sending"] = sorted({e.attrs.get("platform") for e in send[nid]} - {None})
1480
+ elif a["protocol"] in ("pigeon", "flutter", "flutter-event", "capacitor", "react-native", "cordova"):
1481
+ a["direction"] = "to_native"
1482
+ a.pop("checks", None)
1483
+ ck = []
1484
+ proc = a["protocol"] in PROCESS_PROTOCOLS
1485
+ impl = ns_plat.get((a["protocol"], a["namespace"]))
1486
+ if proc:
1487
+ # one app, two processes: a receiver-less channel is a miss when the other side is in the repo at all
1488
+ ext_pkg = any(e.attrs.get("external") for e in send[nid] + tsend[nid])
1489
+ impl = None if ext_pkg else (proto_recv.get(a["protocol"]) or None)
1490
+ base = a.get("method") in BASE_METHODS.get(a["protocol"], ())
1491
+ if base:
1492
+ a["base_method"] = True # the framework's base class implements it on every platform; a module may override
1493
+ if a["protocol"] in EVENT_PROTOCOLS and (send[nid] or tsend[nid]) and not recv[nid]:
1494
+ a["no_listener"] = True # emitted natively, no JS listener found (one may sit in a library / dynamic name)
1495
+ elif (send[nid] or tsend[nid]) and not recv[nid]:
1496
+ if impl:
1497
+ if not base:
1498
+ ck.append("no_receiver")
1499
+ else:
1500
+ a["external"] = True
1501
+ ext = sorted({e.attrs.get("external") for e in send[nid] + tsend[nid]} - {None})
1502
+ if ext:
1503
+ a["package"] = ext[0]
1504
+ if a["protocol"] in EVENT_PROTOCOLS and recv[nid] and not send[nid]:
1505
+ # a listener for an event no native code in the repo emits: a miss only where the repo's native side
1506
+ # emits events at all (Capacitor: the same plugin's native code is here); else a library's event
1507
+ if a["protocol"] == "capacitor-event":
1508
+ here = a["namespace"] in ev_sent.get(a["protocol"], ()) or ns_plat.get(("capacitor", a["namespace"]))
1509
+ else:
1510
+ # NativeEventEmitter(Module) of a module implemented here; DeviceEventEmitter: where the repo's native
1511
+ # code emits events at all
1512
+ mods = {e.attrs.get("emitter_module") for e in recv[nid]}
1513
+ here = any(m and ns_plat.get(("react-native", m)) for m in mods)
1514
+ if not here and not any(mods):
1515
+ # DeviceEventEmitter / an emitter of an unknown module: often an in-app JS event bus whose emits
1516
+ # pass the name in a variable; not a bridge miss, not a library either
1517
+ base = True
1518
+ here = True
1519
+ if not here:
1520
+ a["external"] = True
1521
+ ext = sorted({e.attrs.get("external") for e in recv[nid]} - {None})
1522
+ if ext:
1523
+ a["package"] = ext[0]
1524
+ base = True
1525
+ if recv[nid] and not send[nid] and not base:
1526
+ ck.append("no_sender" if not tsend[nid] else "test_sender_only")
1527
+ if a.get("unregistered"):
1528
+ ck.append("unregistered")
1529
+ if recv[nid] and impl and not base and not proc and not to_app:
1530
+ expected = mobile or sorted(impl - {None})
1531
+ if a["protocol"] == "react-native":
1532
+ declared = set()
1533
+ for e in recv[nid]:
1534
+ dn = builder.nodes.get(e.dst)
1535
+ cp = dn and dn.file and _codegen_platforms(root, dn.file, pj_cache)
1536
+ if not cp:
1537
+ declared = None
1538
+ break
1539
+ declared |= set(cp)
1540
+ if declared:
1541
+ expected = [p for p in expected if p in declared]
1542
+ # Flutter: only the platform folders of the sending app's package (an iOS-only sample has no android/)
1543
+ if a["protocol"] in ("flutter", "flutter-event", "pigeon") and send[nid]:
1544
+ folders = [_flutter_folders(root, e.file, pj_cache) for e in send[nid]]
1545
+ if all(f is not None for f in folders):
1546
+ have = set().union(*folders)
1547
+ expected = [p for p in expected if p in have]
1548
+ # every sender is gated to some platforms (`Platform.OS === 'android'`, kIsWeb, ...): only those need it
1549
+ gated = [e.attrs.get("platforms") for e in send[nid]]
1550
+ if gated and all(g is not None for g in gated): # [] : on none of the targets (#74)
1551
+ only = sorted({p for g in gated for p in g})
1552
+ a["sender_platforms"] = only
1553
+ expected = [p for p in expected if p in only]
1554
+ miss = [p for p in expected if p not in rp]
1555
+ if miss:
1556
+ a["missing_on"] = miss
1557
+ ck.append("missing_on")
1558
+ else:
1559
+ a.pop("missing_on", None)
1560
+ if ck:
1561
+ a["checks"] = ck
1562
+ for c in ck:
1563
+ checks[c] += 1
1564
+ if a.get("external"):
1565
+ checks["external"] += 1
1566
+ st = dict(st or {})
1567
+ st["endpoints"] = len(eps)
1568
+ st["linked"] = sum(1 for nid in eps if send[nid] and recv[nid])
1569
+ st["checks"] = dict(sorted(checks.items()))
1570
+ st["targets"] = mobile
1571
+ return st
1572
+
1573
+
1574
+ # ------------------------------------------------------------------ query: cg bridges / MCP bridges
1575
+ def _attrs(r) -> dict:
1576
+ return json.loads(r["attrs"] or "{}") if r["attrs"] else {}
1577
+
1578
+
1579
+ def bridges(st, pattern: str | None = None, protocol: str | None = None, unmatched: bool = False) -> dict:
1580
+ """Bridge endpoints with their senders (JS / Dart call sites, entry points reaching them) and receivers per
1581
+ platform, plus the checks (missing_on, no_receiver, no_sender, external)."""
1582
+ rows = [dict(r) for r in st.q("SELECT * FROM nodes WHERE kind='endpoint' ORDER BY id")]
1583
+ out = []
1584
+ for n in rows:
1585
+ a = _attrs(n)
1586
+ if a.get("protocol") not in PROTOCOLS or (protocol and a["protocol"] != protocol):
1587
+ continue
1588
+ name = n["name"]
1589
+ if pattern:
1590
+ p = pattern[len("endpoint:"):] if pattern.startswith("endpoint:") else pattern
1591
+ if not (fnmatch.fnmatchcase(name, p) or fnmatch.fnmatchcase(n["id"].split(":", 1)[1], p)
1592
+ or p.lower() in name.lower()):
1593
+ continue
1594
+ if unmatched and not (a.get("checks") or a.get("external")):
1595
+ continue
1596
+ item = {"id": n["id"], "protocol": a["protocol"], "namespace": a.get("namespace"), "method": a.get("method"),
1597
+ "api": a.get("api"), "platforms_received": a.get("platforms_received") or [],
1598
+ "checks": a.get("checks") or [], "missing_on": a.get("missing_on") or [], "external": bool(a.get("external")),
1599
+ "package": a.get("package"), "test_only": bool(a.get("test_only")),
1600
+ "base_method": bool(a.get("base_method")), "sender_platforms": a.get("sender_platforms"),
1601
+ "direction": a.get("direction"), "platforms_sending": a.get("platforms_sending") or [],
1602
+ "no_listener": bool(a.get("no_listener")), "senders": [], "test_senders": [],
1603
+ "receivers": []}
1604
+ for r in st.q("SELECT src, kind, file, line, confidence, attrs FROM edges WHERE dst=? AND kind IN ('SENDS_TO','TEST_CALLS') "
1605
+ "ORDER BY file, line", (n["id"],)):
1606
+ ea = _attrs(r)
1607
+ ents = {e["entry_kind"]: e["entry_count"] for e in
1608
+ st.q("SELECT entry_kind, entry_count FROM node_entry WHERE node_id=?", (r["src"],))}
1609
+ s = {"fn": r["src"], "at": f"{r['file']}:{r['line']}", "confidence": r["confidence"], "via": ea.get("via"),
1610
+ "entry_kinds": ents}
1611
+ if ea.get("process"):
1612
+ s["process"] = ea["process"]
1613
+ (item["senders"] if r["kind"] == "SENDS_TO" else item["test_senders"]).append(s)
1614
+ for r in st.q("SELECT dst, file, line, confidence, attrs FROM edges WHERE src=? AND kind='RECEIVED_BY' ORDER BY file, line",
1615
+ (n["id"],)):
1616
+ ea = _attrs(r)
1617
+ item["receivers"].append({"handler": r["dst"], "at": f"{r['file']}:{r['line']}", "platform": ea.get("platform"),
1618
+ "via": ea.get("via"), "confidence": r["confidence"],
1619
+ "stub": bool(_attrs(st.node(r["dst"]) or {"attrs": None}).get("bridge_stub")),
1620
+ **({"process": ea["process"]} if ea.get("process") else {})})
1621
+ out.append(item)
1622
+ summ = defaultdict(lambda: defaultdict(int))
1623
+ for i in out:
1624
+ s = summ[i["protocol"]]
1625
+ s["endpoints"] += 1
1626
+ s["linked"] += bool(i["senders"] and i["receivers"])
1627
+ for c in i["checks"]:
1628
+ s[c] += 1
1629
+ s["external"] += i["external"]
1630
+ stats = (st.meta().get("stats") or {}).get("bridges") or {}
1631
+ # bridge calls cg could not name (NativeModules[x], addListener(evtVar), cordova.exec(.., svc, act)): listed, not linked
1632
+ dyn = [d for d in ((stats.get("dynamic") or {}).get("samples") or [])
1633
+ if (not protocol or d.get("protocol") == protocol or (d.get("protocol") or "").startswith(protocol + "-"))
1634
+ and (not pattern or pattern.lower() in (d.get("what") or "").lower() or pattern.lower() in (d.get("file") or "").lower())]
1635
+ return {"pattern": pattern, "protocol": protocol, "endpoints": out,
1636
+ "summary": {k: dict(v) for k, v in sorted(summ.items())},
1637
+ "unresolved": dyn, "unresolved_total": (stats.get("dynamic") or {}).get("count", 0) if not (protocol or pattern) else len(dyn),
1638
+ "stats": stats}
1639
+
1640
+
1641
+ def render_bridges(res: dict, max_items: int = 60) -> str:
1642
+ from .query import short_id
1643
+ eps = res["endpoints"]
1644
+ dyn = res.get("unresolved") or []
1645
+ if not eps and not dyn:
1646
+ return "no bridge endpoint matches " + repr(res["pattern"]) if res["pattern"] else "no web / native bridge calls in this graph"
1647
+ L = []
1648
+ for p, s in res["summary"].items():
1649
+ extra = ", ".join(f"{k} {v}" for k, v in s.items() if k not in ("endpoints", "linked") and v)
1650
+ L.append(f"{p}: {s['endpoints']} endpoint(s), {s.get('linked', 0)} linked" + (f" ({extra})" if extra else ""))
1651
+ tg = (res.get("stats") or {}).get("targets")
1652
+ if tg:
1653
+ L.append(f"mobile targets: {', '.join(tg)}")
1654
+ detail = len(eps) <= 6
1655
+ for i in eps[:max_items]:
1656
+ flags = []
1657
+ proc = i["protocol"] in PROCESS_PROTOCOLS
1658
+ if i["missing_on"]:
1659
+ flags.append("MISSING ON " + ", ".join(i["missing_on"]))
1660
+ for c in i["checks"]:
1661
+ if c == "no_receiver":
1662
+ flags.append("NO RECEIVER (this app handles other channels / commands, not this one)" if proc else
1663
+ "NO NATIVE RECEIVER (the module is implemented here, this method is not)")
1664
+ elif c == "no_sender":
1665
+ flags.append("no sender" if proc else "no native sender" if i.get("direction") == "to_app" and
1666
+ i["protocol"] in EVENT_PROTOCOLS else "no JS / Dart sender")
1667
+ elif c == "unregistered":
1668
+ flags.append("NOT REGISTERED (missing from generate_handler!)")
1669
+ elif c == "test_sender_only":
1670
+ flags.append("sent from tests only")
1671
+ if i.get("no_listener"):
1672
+ flags.append("no JS listener found")
1673
+ if i["external"]:
1674
+ flags.append("external" + (f" ({i['package']})" if i.get("package") else ": implemented outside this repo"))
1675
+ where = (f"received in: {', '.join(sorted({r.get('process') or '?' for r in i['receivers']})) or '-'}" if proc else
1676
+ f"sent from: {', '.join(i['platforms_sending']) or '-'}" if i["protocol"] in EVENT_PROTOCOLS else
1677
+ f"received on: {', '.join(i['platforms_received']) or '-'}")
1678
+ L.append(f"{i['id'].split(':', 1)[1]} {where}"
1679
+ + (f" [{i['api']}]" if i.get("api") else "") + (f" ! {'; '.join(flags)}" if flags else ""))
1680
+ if detail:
1681
+ for s in i["senders"][:8]:
1682
+ ek = ", ".join(f"{k}({v})" for k, v in sorted(s["entry_kinds"].items()))
1683
+ L.append(f" sent by {short_id(s['fn'])} @ {s['at']} [{s['confidence']}"
1684
+ + (f", {s['process']}" if s.get("process") else "") + "]" + (f" entries: {ek}" if ek else ""))
1685
+ for s in i["test_senders"][:4]:
1686
+ L.append(f" test {short_id(s['fn'])} @ {s['at']}")
1687
+ for r in i["receivers"]:
1688
+ L.append(f" received by {short_id(r['handler'])} @ {r['at']} [{r.get('process') or r['platform']}, {r['via']}]"
1689
+ + (" (stub: no language plugin)" if r["stub"] else ""))
1690
+ else:
1691
+ L.append(f" senders {len(i['senders'])}" + (f" (+{len(i['test_senders'])} test)" if i["test_senders"] else "")
1692
+ + f", receivers {len(i['receivers'])}")
1693
+ if len(eps) > max_items:
1694
+ L.append(f"... {len(eps) - max_items} more")
1695
+ if dyn:
1696
+ L.append(f"unresolved (name not a literal cg can evaluate): {res.get('unresolved_total') or len(dyn)}")
1697
+ for d in dyn[:12]:
1698
+ L.append(f" {d['protocol']}: {d['what']} @ {d['file']}:{d['line']}")
1699
+ if len(dyn) > 12:
1700
+ L.append(f" ... {len(dyn) - 12} more")
1701
+ return "\n".join(L)