forked from colbymchenry/codegraph
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexpo-modules.ts
More file actions
198 lines (185 loc) · 7.77 KB
/
Copy pathexpo-modules.ts
File metadata and controls
198 lines (185 loc) · 7.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
/**
* Expo Modules framework — close the JS → native flow for Expo SDK packages.
*
* Expo Modules use a Swift / Kotlin DSL distinct from the React Native legacy
* bridge. Each native module is a class extending `Module` whose
* `definition()` body declares the JS surface via literal `Name(...)`,
* `Function(...)`, `AsyncFunction(...)`, `Property(...)`, and `View {...}`
* calls. Tree-sitter parses these as ordinary call_expressions with trailing
* closures, so the JS-visible methods don't exist as named symbol nodes by
* default — `Camera.takePictureAsync(...)` on the JS side has nothing to
* resolve to.
*
* This framework extractor walks the file source for those declarative
* literals and emits method nodes named `takePictureAsync` /
* `notificationAsync` / `width` / etc., attributed to the Swift / Kotlin
* file. The standard name-matcher then resolves JS `Foo.takePictureAsync(...)`
* to them via the existing `obj.method` → method-name path — no separate
* resolve() branch needed.
*
* Real-world shape (expo-haptics):
*
* public class HapticsModule: Module {
* public func definition() -> ModuleDefinition {
* Name("ExpoHaptics")
* AsyncFunction("notificationAsync") { ... }
* AsyncFunction("impactAsync") { ... }
* AsyncFunction("selectionAsync") { ... }
* }
* }
*
* Kotlin Module declarations are the same DSL (the API mirrors Swift).
*
* Anti-goals (deferred):
* - The trailing-closure BODY is not extracted as the method's body — it
* remains attributed to `definition()` in the existing extraction. Future
* work could synthesize a body-range for richer `trace` output, but the
* reachability (which is the bridge's main value) is already complete.
* - `View { ... }` blocks expose JSX prop bindings; that overlaps with
* Fabric (Phase 6) and is left to that phase.
*/
import type { Node } from '../../types';
import {
FrameworkExtractionResult,
FrameworkResolver,
} from '../types';
/**
* Match `Function("name")`, `AsyncFunction("name")`, or `Property("name")`
* at the start of an expression (line-anchored after optional whitespace).
* The trailing closure that follows isn't captured — we just need the name
* literal that becomes the JS-visible method.
*
* NOTE: the regex deliberately requires the open paren to live on the same
* line as the keyword, which matches every real Expo Module declaration
* style. Multi-line `AsyncFunction(\n"x"\n)` forms aren't a real shape in
* the SDK; if any appear we'd extend the regex.
*
* The optional `<…>` covers Kotlin's GENERIC-typed declarations
* (`AsyncFunction<Float>("getBatteryLevelAsync")`, `AsyncFunction<Int, String>(…)`)
* — without it, every Android Expo Module method was silently dropped, so a JS
* callsite resolved only to the iOS Swift impl and never the Android one.
*/
const EXPO_DECL_RE =
/\b(Function|AsyncFunction|Property|Constants)\s*(?:<[^(]*>)?\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
/**
* Match the module name literal `Name("ExpoX")`. Used to enrich each emitted
* method's qualifiedName so the same JS callsite to `Foo.fn` doesn't ambiguate
* across multiple Expo modules in a monorepo.
*/
const EXPO_MODULE_NAME_RE = /\bName\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/;
/**
* Heuristic class-name match — used as a fallback if `Name(...)` literal
* isn't found. Detects `class XxxModule: Module` (Swift) or
* `class XxxModule : Module` (Kotlin / with whitespace tolerance).
*/
const EXPO_CLASS_RE =
/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*Module\b/;
/**
* Detect whether a file is plausibly an Expo Module — looking for both
* the `: Module` inheritance and at least one declarative `Function(...)`
* / `AsyncFunction(...)` / `Property(...)` / `Name(...)` literal. Any one
* of those alone produces too many false positives (random Swift code can
* have `class X: Module` for unrelated reasons).
*/
function isExpoModuleSource(source: string): boolean {
if (!EXPO_CLASS_RE.test(source)) return false;
// Reset lastIndex defensively; EXPO_DECL_RE has the `g` flag.
EXPO_DECL_RE.lastIndex = 0;
return EXPO_DECL_RE.test(source);
}
/**
* Extract Expo Module method declarations from a Swift / Kotlin source
* file. Each `Function("X") { … }` / `AsyncFunction("X") { … }` /
* `Property("X") { … }` literal becomes a method node named `X`,
* attributed to the file at the line of the literal.
*/
function extractExpoMethods(filePath: string, source: string, language: 'swift' | 'kotlin'): Node[] {
if (!isExpoModuleSource(source)) return [];
const nodes: Node[] = [];
const nameMatch = source.match(EXPO_MODULE_NAME_RE);
const classMatch = source.match(EXPO_CLASS_RE);
// Prefer the explicit `Name("X")` literal — that's the JS-visible
// module name. Class name is the fallback.
const moduleName = nameMatch?.[1] ?? classMatch?.[1] ?? 'ExpoModule';
const now = Date.now();
const seenAtLine = new Set<string>();
EXPO_DECL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = EXPO_DECL_RE.exec(source)) !== null) {
const kind = m[1]!;
const methodName = m[2]!;
// Compute line number from match index.
const before = source.slice(0, m.index);
const startLine = before.split('\n').length;
// Avoid duplicates if the same method literal appears twice in one
// file (e.g., declared and re-declared inside a `View {...}` block).
const dedupKey = `${methodName}:${startLine}`;
if (seenAtLine.has(dedupKey)) continue;
seenAtLine.add(dedupKey);
const startColumn = before.length - before.lastIndexOf('\n') - 1;
nodes.push({
id: `expo-module:${filePath}:${moduleName}:${methodName}:${startLine}`,
kind: 'method',
name: methodName,
qualifiedName: `${filePath}::${moduleName}.${methodName}`,
filePath,
language,
startLine,
// We don't extract the closure body's end-line — use the literal's
// line as a single-line range. trace/explore still surfaces the
// declaration site, which is the main user-visible signal.
endLine: startLine,
startColumn,
endColumn: startColumn + kind.length + 2 + methodName.length + 2,
docstring: `Expo Modules ${kind}("${methodName}") in ${moduleName}`,
signature: `${kind}("${methodName}")`,
isExported: true,
updatedAt: now,
});
}
return nodes;
}
export const expoModulesResolver: FrameworkResolver = {
name: 'expo-modules',
languages: ['swift', 'kotlin'],
/**
* Detect Expo Modules by looking at the project's package.json or
* a small scan of source files for the `: Module` + declarative-DSL
* markers. Either signal suffices.
*/
detect(context) {
const pkg = context.readFile('package.json');
if (pkg && /["']expo-modules-core["']\s*:/.test(pkg)) return true;
const files = context.getAllFiles();
for (let i = 0; i < Math.min(files.length, 200); i++) {
const f = files[i];
if (!f) continue;
if (f.endsWith('.swift') || f.endsWith('.kt')) {
const src = context.readFile(f);
if (src && isExpoModuleSource(src)) return true;
}
}
return false;
},
/**
* Per-file extraction — the orchestrator invokes this for every
* `.swift` / `.kt` file in the project. We only emit nodes when the
* file looks like an Expo Module; otherwise return empty.
*/
extract(filePath, source): FrameworkExtractionResult {
const language = filePath.endsWith('.kt') ? 'kotlin' : 'swift';
return {
nodes: extractExpoMethods(filePath, source, language),
references: [],
};
},
/**
* No bespoke resolution needed — the synthetic method nodes emitted by
* `extract()` get picked up by the standard name-matcher when a JS
* callsite like `Foo.takePictureAsync(args)` resolves. Returning null
* here is correct.
*/
resolve() {
return null;
},
};