vxrn 2.0.0-beta.99.1 → 2.6.1

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 (210) hide show
  1. package/dist/backgroundComputation.mjs +145 -0
  2. package/dist/backgroundComputation.mjs.map +1 -0
  3. package/dist/backgroundComputation.native.js +163 -0
  4. package/dist/backgroundComputation.native.js.map +1 -0
  5. package/dist/backgroundComputationNative.mjs +16 -0
  6. package/dist/backgroundComputationNative.mjs.map +1 -0
  7. package/dist/backgroundComputationNative.native.js +18 -0
  8. package/dist/backgroundComputationNative.native.js.map +1 -0
  9. package/dist/config/getBaseViteConfigOnly.mjs +6 -10
  10. package/dist/config/getBaseViteConfigOnly.mjs.map +1 -1
  11. package/dist/config/getBaseViteConfigOnly.native.js +6 -10
  12. package/dist/config/getBaseViteConfigOnly.native.js.map +1 -1
  13. package/dist/config/getOptimizeDeps.mjs +2 -1
  14. package/dist/config/getOptimizeDeps.mjs.map +1 -1
  15. package/dist/config/getOptimizeDeps.native.js +2 -1
  16. package/dist/config/getOptimizeDeps.native.js.map +1 -1
  17. package/dist/config/getViteServerConfig.mjs +13 -0
  18. package/dist/config/getViteServerConfig.mjs.map +1 -1
  19. package/dist/config/getViteServerConfig.native.js +15 -1
  20. package/dist/config/getViteServerConfig.native.js.map +1 -1
  21. package/dist/constants/defaults.mjs +4 -0
  22. package/dist/constants/defaults.mjs.map +1 -1
  23. package/dist/constants/defaults.native.js +4 -0
  24. package/dist/constants/defaults.native.js.map +1 -1
  25. package/dist/exports/build.native.js +1 -1
  26. package/dist/exports/build.native.js.map +1 -1
  27. package/dist/exports/clean.native.js +1 -1
  28. package/dist/exports/clean.native.js.map +1 -1
  29. package/dist/exports/createServer.native.js +2 -2
  30. package/dist/exports/createServer.native.js.map +1 -1
  31. package/dist/exports/dev.mjs +3 -0
  32. package/dist/exports/dev.mjs.map +1 -1
  33. package/dist/exports/dev.native.js +6 -3
  34. package/dist/exports/dev.native.js.map +1 -1
  35. package/dist/exports/loadEnv.native.js +1 -1
  36. package/dist/exports/loadEnv.native.js.map +1 -1
  37. package/dist/exports/patch.native.js +1 -1
  38. package/dist/exports/patch.native.js.map +1 -1
  39. package/dist/exports/prebuild.mjs +0 -22
  40. package/dist/exports/prebuild.mjs.map +1 -1
  41. package/dist/exports/prebuild.native.js +1 -23
  42. package/dist/exports/prebuild.native.js.map +1 -1
  43. package/dist/exports/prebuildWithoutExpo.mjs +1116 -178
  44. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  45. package/dist/exports/prebuildWithoutExpo.native.js +1592 -243
  46. package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
  47. package/dist/exports/runAndroid.native.js +1 -1
  48. package/dist/exports/runAndroid.native.js.map +1 -1
  49. package/dist/exports/runIos.native.js +1 -1
  50. package/dist/exports/runIos.native.js.map +1 -1
  51. package/dist/exports/serve.native.js +1 -1
  52. package/dist/exports/serve.native.js.map +1 -1
  53. package/dist/exports/serveStaticAssets.native.js +1 -1
  54. package/dist/exports/serveStaticAssets.native.js.map +1 -1
  55. package/dist/index.js +4 -1
  56. package/dist/index.mjs +4 -1
  57. package/dist/index.native.js +4 -1
  58. package/dist/native-source-contract.mjs +3 -0
  59. package/dist/native-source-contract.native.js +3 -0
  60. package/dist/patches/builtInDepPatches.mjs +0 -13
  61. package/dist/patches/builtInDepPatches.mjs.map +1 -1
  62. package/dist/patches/builtInDepPatches.native.js +0 -13
  63. package/dist/patches/builtInDepPatches.native.js.map +1 -1
  64. package/dist/plugins/autoDepOptimizePlugin.mjs +3 -2
  65. package/dist/plugins/autoDepOptimizePlugin.mjs.map +1 -1
  66. package/dist/plugins/autoDepOptimizePlugin.native.js +6 -3
  67. package/dist/plugins/autoDepOptimizePlugin.native.js.map +1 -1
  68. package/dist/plugins/backgroundComputationPlugin.mjs +27 -0
  69. package/dist/plugins/backgroundComputationPlugin.mjs.map +1 -0
  70. package/dist/plugins/backgroundComputationPlugin.native.js +32 -0
  71. package/dist/plugins/backgroundComputationPlugin.native.js.map +1 -0
  72. package/dist/plugins/reactNativeCommonJsPlugin.native.js +3 -3
  73. package/dist/plugins/reactNativeCommonJsPlugin.native.js.map +1 -1
  74. package/dist/plugins/reactNativeDevAssetPlugin.mjs +2 -1
  75. package/dist/plugins/reactNativeDevAssetPlugin.mjs.map +1 -1
  76. package/dist/plugins/reactNativeDevAssetPlugin.native.js +3 -2
  77. package/dist/plugins/reactNativeDevAssetPlugin.native.js.map +1 -1
  78. package/dist/plugins/reactNativeDevServer.mjs +87 -24
  79. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  80. package/dist/plugins/reactNativeDevServer.native.js +162 -45
  81. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  82. package/dist/plugins/workletImportsPlugin.mjs +9 -30
  83. package/dist/plugins/workletImportsPlugin.mjs.map +1 -1
  84. package/dist/plugins/workletImportsPlugin.native.js +10 -35
  85. package/dist/plugins/workletImportsPlugin.native.js.map +1 -1
  86. package/dist/rn-commands/bundle/buildBundle.mjs +3 -3
  87. package/dist/rn-commands/bundle/buildBundle.mjs.map +1 -1
  88. package/dist/rn-commands/bundle/buildBundle.native.js +3 -3
  89. package/dist/rn-commands/bundle/buildBundle.native.js.map +1 -1
  90. package/dist/runtime/hmr-client.native.js +3 -7
  91. package/dist/runtime/hmr-client.native.js.map +1 -1
  92. package/dist/runtime/hmr-runtime.native.js +32 -40
  93. package/dist/runtime/hmr-runtime.native.js.map +1 -1
  94. package/dist/serve/node.native.js +2 -2
  95. package/dist/serve/node.native.js.map +1 -1
  96. package/dist/swift-package-id.mjs +3 -0
  97. package/dist/swift-package-id.native.js +3 -0
  98. package/dist/user-interface/index.native.js +6 -6
  99. package/dist/user-interface/index.native.js.map +1 -1
  100. package/dist/utils/compileNativeDevBytecode.mjs +93 -0
  101. package/dist/utils/compileNativeDevBytecode.mjs.map +1 -0
  102. package/dist/utils/compileNativeDevBytecode.native.js +98 -0
  103. package/dist/utils/compileNativeDevBytecode.native.js.map +1 -0
  104. package/dist/utils/createNativeDevEngine.mjs +227 -20
  105. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  106. package/dist/utils/createNativeDevEngine.native.js +249 -37
  107. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  108. package/dist/utils/execSync.native.js +1 -1
  109. package/dist/utils/execSync.native.js.map +1 -1
  110. package/dist/utils/getServerEntry.native.js +2 -2
  111. package/dist/utils/getServerEntry.native.js.map +1 -1
  112. package/dist/utils/nativeRun.mjs +32 -16
  113. package/dist/utils/nativeRun.mjs.map +1 -1
  114. package/dist/utils/nativeRun.native.js +43 -18
  115. package/dist/utils/nativeRun.native.js.map +1 -1
  116. package/dist/utils/nativeSourceContract.mjs +1823 -0
  117. package/dist/utils/nativeSourceContract.mjs.map +1 -0
  118. package/dist/utils/nativeSourceContract.native.js +2425 -0
  119. package/dist/utils/nativeSourceContract.native.js.map +1 -0
  120. package/dist/utils/patches.native.js +15 -24
  121. package/dist/utils/patches.native.js.map +1 -1
  122. package/dist/utils/printServerUrls.native.js +1 -1
  123. package/dist/utils/printServerUrls.native.js.map +1 -1
  124. package/dist/utils/scanDepsToOptimize.mjs +7 -1
  125. package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
  126. package/dist/utils/scanDepsToOptimize.native.js +27 -2
  127. package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
  128. package/dist/utils/swiftPackageId.mjs +31 -2
  129. package/dist/utils/swiftPackageId.mjs.map +1 -1
  130. package/dist/utils/swiftPackageId.native.js +50 -2
  131. package/dist/utils/swiftPackageId.native.js.map +1 -1
  132. package/dist/vxrn-vite-plugin.native.js +1 -1
  133. package/dist/vxrn-vite-plugin.native.js.map +1 -1
  134. package/expo-plugin.cjs +792 -2
  135. package/native-project-patches.cjs +201 -10
  136. package/package.json +34 -15
  137. package/src/backgroundComputation.ts +220 -0
  138. package/src/backgroundComputationNative.ts +23 -0
  139. package/src/config/getBaseViteConfigOnly.ts +8 -9
  140. package/src/config/getOptimizeDeps.ts +3 -1
  141. package/src/config/getViteServerConfig.ts +16 -0
  142. package/src/config/glbAssets.test.ts +228 -0
  143. package/src/constants/defaults.ts +6 -0
  144. package/src/exports/dev.ts +5 -1
  145. package/src/exports/expoPlugin.test.mjs +337 -15
  146. package/src/exports/prebuild.ts +0 -31
  147. package/src/exports/prebuildWithoutExpo.test.ts +1588 -48
  148. package/src/exports/prebuildWithoutExpo.ts +1573 -213
  149. package/src/index.ts +8 -0
  150. package/src/native-source-contract.ts +16 -0
  151. package/src/patches/builtInDepPatches.ts +0 -21
  152. package/src/plugins/autoDepOptimizePlugin.ts +7 -2
  153. package/src/plugins/backgroundComputationPlugin.test.ts +77 -0
  154. package/src/plugins/backgroundComputationPlugin.ts +29 -0
  155. package/src/plugins/reactNativeDevAssetPlugin.ts +5 -1
  156. package/src/plugins/reactNativeDevServer.test.ts +74 -0
  157. package/src/plugins/reactNativeDevServer.ts +145 -28
  158. package/src/plugins/rnCodegenPlugin.test.ts +30 -0
  159. package/src/plugins/workletImportsPlugin.ts +19 -37
  160. package/src/rn-commands/bundle/buildBundle.ts +5 -3
  161. package/src/swift-package-id.ts +1 -0
  162. package/src/utils/compileNativeDevBytecode.test.ts +168 -0
  163. package/src/utils/compileNativeDevBytecode.ts +117 -0
  164. package/src/utils/createNativeDevEngine.test.ts +643 -18
  165. package/src/utils/createNativeDevEngine.ts +278 -48
  166. package/src/utils/nativeRun.test.ts +125 -23
  167. package/src/utils/nativeRun.ts +51 -23
  168. package/src/utils/nativeSourceArtifacts.test.ts +39 -0
  169. package/src/utils/nativeSourceContract.test.ts +600 -0
  170. package/src/utils/nativeSourceContract.ts +1897 -0
  171. package/src/utils/scanDepsToOptimize.test.ts +80 -0
  172. package/src/utils/scanDepsToOptimize.ts +18 -7
  173. package/src/utils/swiftPackageId.ts +25 -1
  174. package/types/backgroundComputation.d.ts +28 -0
  175. package/types/backgroundComputation.d.ts.map +1 -0
  176. package/types/backgroundComputationNative.d.ts +2 -0
  177. package/types/backgroundComputationNative.d.ts.map +1 -0
  178. package/types/config/getBaseViteConfigOnly.d.ts.map +1 -1
  179. package/types/config/getOptimizeDeps.d.ts.map +1 -1
  180. package/types/config/getViteServerConfig.d.ts.map +1 -1
  181. package/types/constants/defaults.d.ts.map +1 -1
  182. package/types/exports/dev.d.ts.map +1 -1
  183. package/types/exports/prebuild.d.ts.map +1 -1
  184. package/types/exports/prebuildWithoutExpo.d.ts +6 -0
  185. package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
  186. package/types/index.d.ts +2 -0
  187. package/types/index.d.ts.map +1 -1
  188. package/types/native-source-contract.d.ts +2 -0
  189. package/types/native-source-contract.d.ts.map +1 -0
  190. package/types/patches/builtInDepPatches.d.ts.map +1 -1
  191. package/types/plugins/autoDepOptimizePlugin.d.ts.map +1 -1
  192. package/types/plugins/backgroundComputationPlugin.d.ts +3 -0
  193. package/types/plugins/backgroundComputationPlugin.d.ts.map +1 -0
  194. package/types/plugins/reactNativeDevAssetPlugin.d.ts.map +1 -1
  195. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  196. package/types/plugins/workletImportsPlugin.d.ts.map +1 -1
  197. package/types/rn-commands/bundle/buildBundle.d.ts.map +1 -1
  198. package/types/swift-package-id.d.ts +2 -0
  199. package/types/swift-package-id.d.ts.map +1 -0
  200. package/types/utils/compileNativeDevBytecode.d.ts +3 -0
  201. package/types/utils/compileNativeDevBytecode.d.ts.map +1 -0
  202. package/types/utils/createNativeDevEngine.d.ts +7 -22
  203. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  204. package/types/utils/nativeRun.d.ts.map +1 -1
  205. package/types/utils/nativeSourceContract.d.ts +121 -0
  206. package/types/utils/nativeSourceContract.d.ts.map +1 -0
  207. package/types/utils/scanDepsToOptimize.d.ts +1 -0
  208. package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
  209. package/types/utils/swiftPackageId.d.ts +1 -0
  210. package/types/utils/swiftPackageId.d.ts.map +1 -1
@@ -0,0 +1,1897 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join, relative } from 'node:path'
4
+ import { swiftPackageDirectories, swiftPackageId } from './swiftPackageId'
5
+
6
+ export interface NativeSourceMethod {
7
+ name: string
8
+ parameters: { name: string; label: string | null; type: string; nativeType: string; wire?: NativeSourceWire }[]
9
+ result: string
10
+ nativeResult: string
11
+ resultWire?: NativeSourceWire
12
+ // a method whose only parameter is a callback subscribes to a native event
13
+ // instead of answering a call: the payload crosses native-to-js per event.
14
+ subscription?: { payload: { type: string; nativeType: string; wire?: NativeSourceWire }[] }
15
+ throws: boolean
16
+ async: boolean
17
+ }
18
+
19
+ export interface NativeSourceModule {
20
+ name: string
21
+ methods: NativeSourceMethod[]
22
+ }
23
+
24
+ // a public @Composable function in a .kt file, or a non-private `struct X: View`
25
+ // in a .swift file: imported as a react component whose props are its
26
+ // parameters (kotlin) or stored properties (swift). a function-typed one is a
27
+ // callback.
28
+ export interface NativeSourceViewProp {
29
+ name: string
30
+ type: string
31
+ nativeType: string
32
+ optional: boolean
33
+ callback: { type: string; nativeType: string; wire?: NativeSourceWire }[] | null
34
+ wire?: NativeSourceWire
35
+ }
36
+
37
+ export interface NativeSourceView {
38
+ name: string
39
+ props: NativeSourceViewProp[]
40
+ }
41
+
42
+ // the wire format of one value crossing the boundary. primitives, void and
43
+ // plain json composites record nothing, so contracts without the richer types
44
+ // keep their exact shape. a struct walks its fields from the structs table;
45
+ // an enum crosses as its raw value and needs no walk.
46
+ export type NativeSourceWire =
47
+ | { kind: 'date' }
48
+ | { kind: 'data' }
49
+ | { kind: 'struct'; name: string }
50
+ | { kind: 'enum'; name: string }
51
+ | { kind: 'array'; element: NativeSourceWire }
52
+ | { kind: 'map'; value: NativeSourceWire }
53
+ | { kind: 'nullable'; inner: NativeSourceWire }
54
+
55
+ export interface NativeSourceStructField {
56
+ name: string
57
+ type: string
58
+ nativeType: string
59
+ optional: boolean
60
+ wire?: NativeSourceWire
61
+ // a kotlin data-class default, embedded by the generated glue when the key
62
+ // is missing; swift fills defaults through Codable synthesis
63
+ fallback?: string
64
+ }
65
+
66
+ export interface NativeSourceStruct {
67
+ name: string
68
+ fields: NativeSourceStructField[]
69
+ }
70
+
71
+ export interface NativeSourceEnum {
72
+ name: string
73
+ backing: 'string' | 'number'
74
+ cases: { name: string; value: string | number }[]
75
+ }
76
+
77
+ export interface NativeSourceContract {
78
+ language: 'swift' | 'kotlin'
79
+ packageName?: string
80
+ modules: NativeSourceModule[]
81
+ views: NativeSourceView[]
82
+ structs: NativeSourceStruct[]
83
+ enums: NativeSourceEnum[]
84
+ defaultView: boolean
85
+ hash: string
86
+ declaration: string
87
+ }
88
+
89
+ export interface SwiftPackageArtifacts {
90
+ packageId: string
91
+ hash: string
92
+ files: Record<string, string[]>
93
+ modules: Record<string, string[]>
94
+ views: Record<string, string[]>
95
+ defaultViewFiles: string[]
96
+ contracts: NativeSourceContract[]
97
+ // null when the package exports no module or typed view
98
+ glue: string | null
99
+ }
100
+
101
+ const GENERATED_HEADER = '// generated by one typegen; edits will be replaced\n'
102
+ const SKIP_SOURCE_DIRS = new Set(['node_modules', 'ios', 'android', 'dist', 'types', 'build', '.build'])
103
+
104
+ function writeGeneratedFile(file: string, content: string): void {
105
+ try {
106
+ if (readFileSync(file, 'utf8') === content) return
107
+ } catch (error) {
108
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') throw error
109
+ }
110
+ const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`)
111
+ try {
112
+ writeFileSync(temporary, content)
113
+ renameSync(temporary, file)
114
+ } finally {
115
+ rmSync(temporary, { force: true })
116
+ }
117
+ }
118
+
119
+ interface Token {
120
+ text: string
121
+ line: number
122
+ column: number
123
+ }
124
+
125
+ function fail(file: string, token: Token | undefined, message: string): never {
126
+ throw new Error(`${file}:${(token?.line ?? 0) + 1}:${(token?.column ?? 0) + 1}: ${message}`)
127
+ }
128
+
129
+ // only declarations are read. strings and comments cannot supply a marker,
130
+ // method, brace, or type; native compilers validate every emitted call.
131
+ function tokens(file: string, source: string): Token[] {
132
+ const result: Token[] = []
133
+ let offset = 0
134
+ let line = 0
135
+ let column = 0
136
+ const advance = () => {
137
+ if (source[offset] === '\n') { line++; column = 0 }
138
+ else column++
139
+ offset++
140
+ }
141
+ while (offset < source.length) {
142
+ const char = source[offset]
143
+ if (/\s/.test(char)) { advance(); continue }
144
+ if (source.startsWith('//', offset)) {
145
+ while (offset < source.length && source[offset] !== '\n') advance()
146
+ continue
147
+ }
148
+ if (source.startsWith('/*', offset)) {
149
+ let depth = 0
150
+ do {
151
+ if (source.startsWith('/*', offset)) { depth++; advance(); advance() }
152
+ else if (source.startsWith('*/', offset)) { depth--; advance(); advance() }
153
+ else advance()
154
+ } while (depth > 0 && offset < source.length)
155
+ if (depth > 0) fail(file, { text: '/*', line, column }, 'unclosed native source comment')
156
+ continue
157
+ }
158
+ if (char === '"' || char === "'") {
159
+ const quote = source.startsWith(char.repeat(3), offset) ? char.repeat(3) : char
160
+ for (let n = 0; n < quote.length; n++) advance()
161
+ while (offset < source.length && !source.startsWith(quote, offset)) {
162
+ if (source[offset] === '\\') advance()
163
+ advance()
164
+ }
165
+ if (offset >= source.length) fail(file, { text: quote, line, column }, 'unclosed native source string')
166
+ for (let n = 0; n < quote.length; n++) advance()
167
+ continue
168
+ }
169
+ const start = offset
170
+ const startLine = line
171
+ const startColumn = column
172
+ if (/[A-Za-z_]/.test(char)) {
173
+ while (offset < source.length && /[A-Za-z0-9_]/.test(source[offset])) advance()
174
+ } else if (source.startsWith('->', offset)) {
175
+ advance(); advance()
176
+ } else advance()
177
+ result.push({ text: source.slice(start, offset), line: startLine, column: startColumn })
178
+ }
179
+ return result
180
+ }
181
+
182
+ function close(file: string, list: Token[], start: number, left: string, right: string): number {
183
+ let depth = 0
184
+ for (let i = start; i < list.length; i++) {
185
+ if (list[i].text === left) depth++
186
+ if (list[i].text === right && --depth === 0) return i
187
+ }
188
+ fail(file, list[start], `unclosed ${left} in native export`)
189
+ }
190
+
191
+ function split(list: Token[], separator: string): Token[][] {
192
+ const parts: Token[][] = []
193
+ let start = 0
194
+ let square = 0
195
+ let angle = 0
196
+ let round = 0
197
+ for (let i = 0; i < list.length; i++) {
198
+ const value = list[i].text
199
+ if (value === '[') square++
200
+ if (value === ']') square--
201
+ if (value === '<') angle++
202
+ if (value === '>') angle--
203
+ if (value === '(') round++
204
+ if (value === ')') round--
205
+ if (value === separator && square === 0 && angle === 0 && round === 0) {
206
+ parts.push(list.slice(start, i))
207
+ start = i + 1
208
+ }
209
+ }
210
+ parts.push(list.slice(start))
211
+ return parts
212
+ }
213
+
214
+ // an optional element is a union, which binds looser than `[]`
215
+ function arrayOf(element: string): string {
216
+ return element.includes(' | ') ? `(${element})[]` : `${element}[]`
217
+ }
218
+
219
+ interface NamedTypes {
220
+ structs: Map<string, { codable: boolean; generic: boolean; problem: string | null }>
221
+ enums: Map<string, { usable: boolean; problem: string }>
222
+ }
223
+
224
+ function tsType(
225
+ file: string,
226
+ list: Token[],
227
+ language: 'swift' | 'kotlin',
228
+ named: NamedTypes
229
+ ): { text: string; wire: NativeSourceWire | null } {
230
+ if (list.length === 0) fail(file, list[0], 'native export needs an explicit type')
231
+ if (list.at(-1)?.text === '?') {
232
+ const inner = tsType(file, list.slice(0, -1), language, named)
233
+ return {
234
+ text: `${inner.text} | null`,
235
+ wire: inner.wire ? { kind: 'nullable', inner: inner.wire } : null,
236
+ }
237
+ }
238
+ const value = list.map((token) => token.text).join('')
239
+ if (value === 'String') return { text: 'string', wire: null }
240
+ if (value === 'Bool' || value === 'Boolean') return { text: 'boolean', wire: null }
241
+ if (['Double', 'Float', 'Int', 'Long'].includes(value)) return { text: 'number', wire: null }
242
+ if (value === 'Void' || value === 'Unit') return { text: 'void', wire: null }
243
+ // Date crosses as an ISO string revived to a Date; Data and ByteArray cross
244
+ // as base64 revived to a Uint8Array. the qualified stdlib spellings mean
245
+ // the same types.
246
+ const dateTypes = language === 'swift' ? ['Date', 'Foundation.Date'] : ['Date', 'java.util.Date']
247
+ if (dateTypes.includes(value)) return { text: 'Date', wire: { kind: 'date' } }
248
+ const dataTypes = language === 'swift' ? ['Data', 'Foundation.Data'] : ['ByteArray']
249
+ if (dataTypes.includes(value)) {
250
+ return { text: 'Uint8Array', wire: { kind: 'data' } }
251
+ }
252
+ if (language === 'swift' && list[0].text === '[' && list.at(-1)?.text === ']') {
253
+ const inner = list.slice(1, -1)
254
+ const entries = split(inner, ':')
255
+ if (entries.length === 1) {
256
+ const element = tsType(file, inner, language, named)
257
+ return {
258
+ text: arrayOf(element.text),
259
+ wire: element.wire ? { kind: 'array', element: element.wire } : null,
260
+ }
261
+ }
262
+ if (entries.length === 2 && entries[0].map((token) => token.text).join('') === 'String') {
263
+ const element = tsType(file, entries[1], language, named)
264
+ return {
265
+ text: `Record<string, ${element.text}>`,
266
+ wire: element.wire ? { kind: 'map', value: element.wire } : null,
267
+ }
268
+ }
269
+ }
270
+ if (language === 'kotlin' && list[1]?.text === '<' && list.at(-1)?.text === '>') {
271
+ const entries = split(list.slice(2, -1), ',')
272
+ if (list[0].text === 'List' && entries.length === 1) {
273
+ const element = tsType(file, entries[0], language, named)
274
+ return {
275
+ text: arrayOf(element.text),
276
+ wire: element.wire ? { kind: 'array', element: element.wire } : null,
277
+ }
278
+ }
279
+ if (list[0].text === 'Map' && entries.length === 2 && entries[0].map((token) => token.text).join('') === 'String') {
280
+ const element = tsType(file, entries[1], language, named)
281
+ return {
282
+ text: `Record<string, ${element.text}>`,
283
+ wire: element.wire ? { kind: 'map', value: element.wire } : null,
284
+ }
285
+ }
286
+ }
287
+ // a struct or enum declared in this file crosses as its JSON shape; any
288
+ // other named type is still rejected below.
289
+ if (list.length === 1 && /^[A-Z][A-Za-z0-9_]*$/.test(value)) {
290
+ const struct = named.structs.get(value)
291
+ if (struct) {
292
+ if (struct.generic) fail(file, list[0], `generic struct ${value} is unsupported`)
293
+ if (struct.problem) fail(file, list[0], struct.problem)
294
+ if (!struct.codable) fail(file, list[0], `struct ${value} must conform to Codable to cross the boundary`)
295
+ return { text: value, wire: { kind: 'struct', name: value } }
296
+ }
297
+ const enumType = named.enums.get(value)
298
+ if (enumType) {
299
+ if (!enumType.usable) fail(file, list[0], enumType.problem)
300
+ return { text: value, wire: { kind: 'enum', name: value } }
301
+ }
302
+ }
303
+ fail(file, list[0], `unsupported native export type ${value}`)
304
+ }
305
+
306
+ function parameters(
307
+ file: string,
308
+ list: Token[],
309
+ language: 'swift' | 'kotlin',
310
+ named: NamedTypes
311
+ ): NativeSourceMethod['parameters'] {
312
+ if (list.length === 0) return []
313
+ return split(list, ',').map((part, index) => {
314
+ if (part.some((token) => token.text === '=')) fail(file, part[0], 'default native export arguments are unsupported')
315
+ const colon = part.findIndex((token) => token.text === ':')
316
+ if (colon < 0) fail(file, part[0], 'native export parameter needs an explicit type')
317
+ const names = part.slice(0, colon).filter((token) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(token.text))
318
+ const name = (language === 'swift' ? names.at(-1) : names[0])?.text ?? `arg${index}`
319
+ if (name === '_') fail(file, part[0], 'native export parameter needs an internal name')
320
+ const label = language === 'swift' ? names[0]?.text ?? name : null
321
+ const sourceType = part.slice(colon + 1)
322
+ const type = tsType(file, sourceType, language, named)
323
+ return {
324
+ name,
325
+ label,
326
+ type: type.text,
327
+ nativeType: sourceType.map((token) => token.text).join(''),
328
+ wire: type.wire ?? undefined,
329
+ }
330
+ })
331
+ }
332
+
333
+ const DECLARATION_MODIFIERS = new Set([
334
+ 'private', 'fileprivate', 'public', 'internal', 'open', 'override', 'static', 'class', 'final',
335
+ 'suspend', 'inline', 'operator', 'infix', 'tailrec', 'external', 'nonisolated', 'isolated',
336
+ 'mutating', 'nonmutating', 'dynamic', 'required', 'convenience', 'optional',
337
+ ])
338
+
339
+ // a method's modifiers are the words and attributes directly before its keyword:
340
+ // a property declared above it (`private var hits = 0`) is not part of it
341
+ function declarationStart(list: Token[], floor: number, keyword: number): number {
342
+ let first = keyword
343
+ while (first > floor) {
344
+ const text = list[first - 1].text
345
+ if (DECLARATION_MODIFIERS.has(text)) { first--; continue }
346
+ if (list[first - 2]?.text === '@') { first -= 2; continue }
347
+ if (text === ')') {
348
+ let open = first - 1
349
+ for (let depth = 0; open > floor; open--) {
350
+ if (list[open].text === ')') depth++
351
+ if (list[open].text === '(' && --depth === 0) break
352
+ }
353
+ if (list[open - 2]?.text === '@') { first = open - 2; continue }
354
+ }
355
+ break
356
+ }
357
+ return first
358
+ }
359
+
360
+ // the payload of a native event subscription: a method whose only parameter
361
+ // is one callback over json types, `func onTick(_ callback: (Double) -> Void)`
362
+ // or `fun onTick(callback: (Double) -> Unit)`. a stored callback must escape,
363
+ // so a leading @escaping is skipped; anything else (nullable, throwing,
364
+ // extra parameters) returns null, and the normal call path rejects it.
365
+ function subscriptionPayload(
366
+ file: string,
367
+ typeTokens: Token[],
368
+ language: 'swift' | 'kotlin',
369
+ named: NamedTypes
370
+ ): { type: string; nativeType: string; wire?: NativeSourceWire }[] | null {
371
+ try {
372
+ if (typeTokens.at(-1)?.text === '?') return null
373
+ let fn = typeTokens
374
+ if (language === 'swift' && fn[0]?.text === '@' && fn[1]?.text === 'escaping') fn = fn.slice(2)
375
+ if (fn[0]?.text === '(' && close(file, fn, 0, '(', ')') === fn.length - 1 && fn.some((token) => token.text === '->')) {
376
+ fn = fn.slice(1, -1)
377
+ }
378
+ const arrow = fn.findIndex((token) => token.text === '->')
379
+ if (arrow <= 0 || fn[0]?.text !== '(' || close(file, fn, 0, '(', ')') !== arrow - 1) return null
380
+ const result = fn.slice(arrow + 1)
381
+ const voidResult =
382
+ (result.length === 1 && (result[0].text === 'Void' || result[0].text === 'Unit')) ||
383
+ (result.length === 2 && result[0].text === '(' && result[1].text === ')')
384
+ if (!voidResult) return null
385
+ const inner = fn.slice(1, arrow - 1)
386
+ if (inner.length === 0) return []
387
+ return split(inner, ',').map((arg) => {
388
+ const colon = arg.findIndex((token) => token.text === ':')
389
+ const type = colon < 0 ? arg : arg.slice(colon + 1)
390
+ const resolved = tsType(file, type, language, named)
391
+ return {
392
+ type: resolved.text,
393
+ nativeType: type.map((token) => token.text).join(''),
394
+ wire: resolved.wire ?? undefined,
395
+ }
396
+ })
397
+ } catch {
398
+ return null
399
+ }
400
+ }
401
+
402
+ function methods(
403
+ file: string,
404
+ list: Token[],
405
+ start: number,
406
+ end: number,
407
+ language: 'swift' | 'kotlin',
408
+ named: NamedTypes
409
+ ): NativeSourceMethod[] {
410
+ const result: NativeSourceMethod[] = []
411
+ let i = start + 1
412
+ let previous = i
413
+ const keyword = language === 'swift' ? 'func' : 'fun'
414
+ while (i < end) {
415
+ if (list[i].text === '{') { i = close(file, list, i, '{', '}') + 1; previous = i; continue }
416
+ if (list[i].text !== keyword) { i++; continue }
417
+ const modifiers = list.slice(declarationStart(list, previous, i), i).map((token) => token.text)
418
+ const hidden = modifiers.includes('private') || modifiers.includes('fileprivate')
419
+ if (!hidden && (modifiers.includes('static') || modifiers.includes('class'))) {
420
+ fail(file, list[i], 'static native exports are unsupported')
421
+ }
422
+ const name = list[i + 1]?.text
423
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) fail(file, list[i], 'native export has no method name')
424
+ if (result.some((method) => method.name === name)) fail(file, list[i], `overloaded native export ${name} is unsupported`)
425
+ if (list[i + 2]?.text === '<') fail(file, list[i], 'generic native exports are unsupported')
426
+ const open = i + 2
427
+ if (list[open]?.text !== '(') fail(file, list[i], 'native export has no parameter list')
428
+ const closing = close(file, list, open, '(', ')')
429
+ let headerEnd = closing + 1
430
+ while (headerEnd < end && !['{', '=', ';'].includes(list[headerEnd].text)) headerEnd++
431
+ const header = list.slice(closing + 1, headerEnd)
432
+ const separator = header.findIndex((token) => token.text === (language === 'swift' ? '->' : ':'))
433
+ const sourceType = hidden || separator < 0 ? [] : header.slice(separator + 1)
434
+ const type = sourceType.length === 0 ? { text: 'void', wire: null } : tsType(file, sourceType, language, named)
435
+ // a single-callback void method subscribes to a native event instead of
436
+ // answering a call. the probe runs before parameters so the callback
437
+ // never hits call typing, which rejects it.
438
+ let subscription: NativeSourceMethod['subscription'] | undefined
439
+ if (!hidden && type.text === 'void') {
440
+ const parts = split(list.slice(open + 1, closing), ',')
441
+ if (parts.length === 1 && parts[0].length > 0) {
442
+ const colon = parts[0].findIndex((token) => token.text === ':')
443
+ if (colon >= 0) {
444
+ const payload = subscriptionPayload(file, parts[0].slice(colon + 1), language, named)
445
+ if (payload) subscription = { payload }
446
+ }
447
+ }
448
+ }
449
+ if (!hidden) {
450
+ result.push({
451
+ name,
452
+ parameters: subscription ? [] : parameters(file, list.slice(open + 1, closing), language, named),
453
+ result: type.text,
454
+ nativeResult: sourceType.length === 0 ? language === 'swift' ? 'Void' : 'Unit' : sourceType.map((token) => token.text).join(''),
455
+ resultWire: type.wire ?? undefined,
456
+ subscription,
457
+ throws: language === 'swift' ? header.some((token) => token.text === 'throws') : true,
458
+ async: language === 'swift' ? header.some((token) => token.text === 'async') : modifiers.includes('suspend'),
459
+ })
460
+ }
461
+ if (list[headerEnd]?.text === '{') i = close(file, list, headerEnd, '{', '}') + 1
462
+ else if (list[headerEnd]?.text === '=') {
463
+ const bodyLine = list[headerEnd].line
464
+ i = headerEnd + 1
465
+ while (i < end && list[i].line === bodyLine && list[i].text !== ';') i++
466
+ } else i = headerEnd + 1
467
+ previous = i
468
+ }
469
+ return result
470
+ }
471
+
472
+ // a function-typed prop: `(A, B) -> Unit` in kotlin, `(A, B) -> Void` in
473
+ // swift, optionally parenthesized and nullable. null for any other type.
474
+ function callbackProp(
475
+ file: string,
476
+ name: string,
477
+ typeTokens: Token[],
478
+ optional: boolean,
479
+ language: 'swift' | 'kotlin',
480
+ named: NamedTypes
481
+ ): NativeSourceViewProp | null {
482
+ const nullable = typeTokens.at(-1)?.text === '?'
483
+ let fn = nullable ? typeTokens.slice(0, -1) : typeTokens
484
+ if (fn[0]?.text === '(' && close(file, fn, 0, '(', ')') === fn.length - 1 && fn.some((token) => token.text === '->')) {
485
+ fn = fn.slice(1, -1)
486
+ }
487
+ const arrow = fn.findIndex((token) => token.text === '->')
488
+ if (arrow < 0 || fn[0]?.text !== '(' || close(file, fn, 0, '(', ')') !== arrow - 1) return null
489
+ const result = fn.slice(arrow + 1).map((token) => token.text).join('')
490
+ const unit = language === 'swift' ? 'Void' : 'Unit'
491
+ if (result !== unit && !(language === 'swift' && result === '()')) {
492
+ fail(file, typeTokens[0], `view callback ${name} must return ${unit}`)
493
+ }
494
+ const inner = fn.slice(1, arrow - 1)
495
+ const callback = inner.length === 0 ? [] : split(inner, ',').map((arg) => {
496
+ // a labeled closure parameter (`_ value: Double`) names only its type
497
+ const colon = arg.findIndex((token) => token.text === ':')
498
+ const type = colon < 0 ? arg : arg.slice(colon + 1)
499
+ const resolved = tsType(file, type, language, named)
500
+ return {
501
+ type: resolved.text,
502
+ nativeType: type.map((token) => token.text).join(''),
503
+ wire: resolved.wire ?? undefined,
504
+ }
505
+ })
506
+ const signature = `(${callback.map((arg, index) => `value${index}: ${arg.type}`).join(', ')}) => void`
507
+ const nativeType = typeTokens.map((token) => token.text).join('')
508
+ return { name, type: nullable ? `(${signature}) | null` : signature, nativeType, optional, callback }
509
+ }
510
+
511
+ function viewProp(file: string, part: Token[], named: NamedTypes): NativeSourceViewProp | null {
512
+ const colon = part.findIndex((token) => token.text === ':')
513
+ if (colon !== 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part[0].text)) {
514
+ fail(file, part[0], 'composable parameter needs a name and an explicit type')
515
+ }
516
+ const name = part[0].text
517
+ const equals = split(part.slice(colon + 1), '=')
518
+ const typeTokens = equals[0]
519
+ const fallback = equals[1]?.map((token) => token.text).join('')
520
+ const nativeType = typeTokens.map((token) => token.text).join('')
521
+ // the host supplies the modifier: the composable keeps its own default
522
+ if (nativeType === 'Modifier') {
523
+ if (fallback !== 'Modifier') fail(file, part[0], 'a composable modifier parameter needs the default Modifier')
524
+ return null
525
+ }
526
+ const nullable = typeTokens.at(-1)?.text === '?'
527
+ if (fallback !== undefined && !(nullable && fallback === 'null')) {
528
+ fail(file, part[0], `composable parameter ${name} may only default to null`)
529
+ }
530
+ const optional = fallback !== undefined
531
+ const callback = callbackProp(file, name, typeTokens, optional, 'kotlin', named)
532
+ if (callback) return callback
533
+ const resolved = tsType(file, typeTokens, 'kotlin', named)
534
+ return { name, type: resolved.text, nativeType, optional, callback: null, wire: resolved.wire ?? undefined }
535
+ }
536
+
537
+ // attributes that annotate a stored property without wrapping it
538
+ const PLAIN_ATTRIBUTES = new Set(['MainActor', 'available', 'objc', 'nonobjc', 'preconcurrency'])
539
+
540
+ // splits one stored `let`/`var` declaration into its span: the property name,
541
+ // its type tokens, whether it carries a value, and the token after it.
542
+ // imported views and data structs share it so both read the same declarations.
543
+ function storedProperty(
544
+ file: string,
545
+ list: Token[],
546
+ i: number,
547
+ previous: number,
548
+ end: number,
549
+ owner: string
550
+ ): {
551
+ at: Token
552
+ own: string[]
553
+ propName: string
554
+ typeTokens: Token[]
555
+ valued: boolean
556
+ stop: number
557
+ computed: boolean
558
+ resume: number
559
+ } {
560
+ const at = list[i]
561
+ const own = list.slice(declarationStart(list, previous, i), i).map((token) => token.text)
562
+ const propName = list[i + 1]?.text ?? ''
563
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(propName)) fail(file, list[i], `${owner} has a property without a simple name`)
564
+ // the declaration runs to its value, its accessor block, or the line's end
565
+ let stop = i + 2
566
+ let depth = 0
567
+ while (stop < end) {
568
+ const value = list[stop].text
569
+ if (depth === 0 && (value === '=' || value === '{' || value === ';' || list[stop].line !== list[stop - 1].line)) break
570
+ if (value === '(' || value === '[' || value === '<') depth++
571
+ if (value === ')' || value === ']' || value === '>') depth--
572
+ stop++
573
+ }
574
+ const typeTokens = list[i + 2]?.text === ':' ? list.slice(i + 3, stop) : []
575
+ const valued = list[stop]?.text === '='
576
+ let next = stop
577
+ if (valued) {
578
+ const valueLine = list[stop].line
579
+ while (next < end && list[next].line === valueLine && list[next].text !== '{') next++
580
+ }
581
+ const block = list[next]?.text === '{'
582
+ // willSet and didSet observe a stored property; any other block computes one
583
+ const computed = block && !valued && !['willSet', 'didSet'].includes(list[next + 1]?.text)
584
+ const resume = block ? close(file, list, next, '{', '}') + 1 : next
585
+ return { at, own, propName, typeTokens, valued, stop, computed, resume }
586
+ }
587
+
588
+ // a top-level public struct conforming to View, or null for any other struct.
589
+ // its props are the memberwise initializer's parameters: the stored
590
+ // properties without a value of their own, in declaration order. a property
591
+ // with a value (`@State var count = 0`, `let title = "x"`) or a computed one
592
+ // (`var body`) stays the view's own.
593
+ function swiftView(
594
+ file: string,
595
+ list: Token[],
596
+ keyword: number,
597
+ floor: number,
598
+ body: number,
599
+ end: number,
600
+ named: NamedTypes
601
+ ): NativeSourceView | null {
602
+ const modifiers = list.slice(declarationStart(list, floor, keyword), keyword).map((token) => token.text)
603
+ if (list[keyword].text !== 'struct' || !modifiers.includes('public')) return null
604
+ if (!list.slice(keyword + 2, body).some((token) => token.text === 'View')) return null
605
+ const name = list[keyword + 1].text
606
+ if (!/^[A-Z][A-Za-z0-9_]*$/.test(name)) fail(file, list[keyword], 'an imported view needs a capitalized name')
607
+ if (list[keyword + 2]?.text === '<') fail(file, list[keyword], `generic view ${name} is unsupported`)
608
+ const props: NativeSourceViewProp[] = []
609
+ let i = body + 1
610
+ let previous = i
611
+ while (i < end) {
612
+ const text = list[i].text
613
+ if (text === '{') { i = close(file, list, i, '{', '}') + 1; previous = i; continue }
614
+ if (text === 'init' && list[i + 1]?.text === '(' && list[i - 1]?.text !== '.') {
615
+ fail(file, list[i], `view ${name} is imported through its memberwise initializer; remove init(...)`)
616
+ }
617
+ if (text !== 'let' && text !== 'var') { i++; continue }
618
+ const property = storedProperty(file, list, i, previous, end, `view ${name}`)
619
+ const { at, own, propName, typeTokens, valued, stop } = property
620
+ i = property.resume
621
+ previous = i
622
+ const wrapper = own.findIndex((value, index) => own[index - 1] === '@' && !PLAIN_ATTRIBUTES.has(value))
623
+ if (property.computed || own.includes('static')) continue
624
+ if (wrapper >= 0) {
625
+ // a wrapper with arguments (`@Environment(\.dismiss)`) supplies its own value
626
+ if (valued || own[wrapper + 1] === '(') continue
627
+ fail(file, at, `@${own[wrapper]} ${propName} in view ${name} needs a value; pass a plain value and a callback instead`)
628
+ }
629
+ if (own.includes('private') || own.includes('fileprivate')) {
630
+ if (valued) continue
631
+ fail(file, at, `private ${propName} in view ${name} needs a value, or the view cannot be built from javascript`)
632
+ }
633
+ if (valued && text === 'let') continue
634
+ if (typeTokens.length === 0) fail(file, at, `view prop ${propName} needs an explicit type`)
635
+ const nullable = typeTokens.at(-1)?.text === '?'
636
+ if (valued && !(nullable && list[stop + 1]?.text === 'nil' && list[stop + 2]?.line !== list[stop + 1].line)) {
637
+ fail(file, list[stop], `view prop ${propName} may only default to nil`)
638
+ }
639
+ const callback = callbackProp(file, propName, typeTokens, nullable, 'swift', named)
640
+ if (callback) {
641
+ props.push(callback)
642
+ } else {
643
+ const resolved = tsType(file, typeTokens, 'swift', named)
644
+ props.push({
645
+ name: propName,
646
+ type: resolved.text,
647
+ nativeType: typeTokens.map((token) => token.text).join(''),
648
+ optional: nullable,
649
+ callback: null,
650
+ wire: resolved.wire ?? undefined,
651
+ })
652
+ }
653
+ }
654
+ return { name, props }
655
+ }
656
+
657
+ // a struct or enum declaration one method, prop or callback may reference.
658
+ // field and case details parse lazily: files that never use a declaration as
659
+ // data keep parsing exactly as before, callbacks and wrappers included.
660
+ interface CollectedStruct {
661
+ name: string
662
+ token: Token
663
+ generic: boolean
664
+ codable: boolean
665
+ problem: string | null
666
+ body: Token[]
667
+ // kotlin data-class constructor parameter slices, empty for swift
668
+ params: Token[][]
669
+ packageName: string | undefined
670
+ }
671
+
672
+ interface CollectedEnum {
673
+ name: string
674
+ token: Token
675
+ generic: boolean
676
+ usable: boolean
677
+ problem: string
678
+ backing: 'string' | 'number'
679
+ cases: { name: string; value: string | number }[]
680
+ }
681
+
682
+ // a top-level `data class Name(...)` in kotlin. the constructor parameters
683
+ // are the fields; the generated glue calls the constructor by name, so it
684
+ // must stay public and every parameter keeps its slice for finalization.
685
+ function collectDataClass(
686
+ file: string,
687
+ list: Token[],
688
+ keyword: number,
689
+ name: string,
690
+ token: Token,
691
+ generic: boolean,
692
+ packageName: string | undefined
693
+ ): CollectedStruct | null {
694
+ // the generated glue compiles into the same module, so internal stays
695
+ // visible; only private hides the class.
696
+ const modifiers = list.slice(declarationStart(list, 0, keyword - 1), keyword).map((entry) => entry.text)
697
+ let problem: string | null = null
698
+ if (modifiers.includes('private')) {
699
+ problem = `data class ${name} must be public to cross the boundary`
700
+ }
701
+ let scan = keyword + 2
702
+ if (list[scan]?.text === '<') scan = close(file, list, scan, '<', '>') + 1
703
+ // skip visibility and constructor keywords: `private constructor(...)`
704
+ while (['private', 'protected', 'public', 'internal', 'constructor'].includes(list[scan]?.text)) scan++
705
+ const params: Token[][] = []
706
+ if (list[scan]?.text === '(') {
707
+ const closing = close(file, list, scan, '(', ')')
708
+ for (const part of split(list.slice(scan + 1, closing), ',')) {
709
+ if (part.length > 0) params.push(part)
710
+ }
711
+ } else {
712
+ // not a data-class declaration (`data` was an identifier); skip it
713
+ return null
714
+ }
715
+ // a private constructor hides the class from the generated glue
716
+ if (list.slice(keyword + 2, scan).some((entry) => entry.text === 'private' || entry.text === 'protected')) {
717
+ problem = problem ?? `data class ${name} must be public to cross the boundary`
718
+ }
719
+ return { name, token, generic, codable: true, problem, body: [], params, packageName }
720
+ }
721
+
722
+ // a top-level `enum Name: String` / `enum Name: Int` in swift. only implicit
723
+ // raw values cross: the tokenizer drops string contents, so an explicit value
724
+ // has no readable form. associated values never cross.
725
+ function collectSwiftEnum(
726
+ file: string,
727
+ body: Token[],
728
+ name: string,
729
+ token: Token,
730
+ generic: boolean,
731
+ header: Token[]
732
+ ): CollectedEnum {
733
+ const backing = header.some((entry) => entry.text === 'String')
734
+ ? 'string'
735
+ : header.some((entry) => entry.text === 'Int')
736
+ ? 'number'
737
+ : null
738
+ if (generic) {
739
+ return { name, token, generic, usable: false, problem: `generic enum ${name} is unsupported`, backing: 'string', cases: [] }
740
+ }
741
+ if (!backing) {
742
+ return { name, token, generic, usable: false, problem: `enum ${name} needs a String or Int backing to cross the boundary`, backing: 'string', cases: [] }
743
+ }
744
+ // the generated glue decodes through Codable, which RawRepresentable enums
745
+ // only synthesize with a declared conformance
746
+ const inherits = header.map((entry) => entry.text)
747
+ if (!inherits.includes('Codable') && !(inherits.includes('Decodable') && inherits.includes('Encodable'))) {
748
+ return { name, token, generic, usable: false, problem: `enum ${name} must conform to Codable to cross the boundary`, backing, cases: [] }
749
+ }
750
+ // words that start a new member: a case list ends before any of them, so
751
+ // comma-first and multi-line case lists keep working.
752
+ const memberStarters = new Set([
753
+ 'func', 'var', 'let', 'init', 'deinit', 'subscript', 'static', 'public',
754
+ 'private', 'fileprivate', 'internal', 'open', 'final', 'mutating',
755
+ 'nonmutating', 'override', 'lazy', 'weak', 'unowned', 'required',
756
+ 'convenience', 'dynamic', 'indirect', 'typealias', 'import',
757
+ ])
758
+ const cases: { name: string; value: string | number }[] = []
759
+ let problem: string | null = null
760
+ let counter = 0
761
+ let i = 0
762
+ let depth = 0
763
+ while (i < body.length) {
764
+ if (body[i].text === '{') { depth++; i++; continue }
765
+ if (body[i].text === '}') { depth--; i++; continue }
766
+ // cases inside a member (a switch in a method) are not enum cases
767
+ if (depth !== 0 || body[i].text !== 'case') { i++; continue }
768
+ let stop = i + 1
769
+ let inner = 0
770
+ while (stop < body.length) {
771
+ const value = body[stop].text
772
+ if (inner === 0 && (value === ';' || value === 'case' || memberStarters.has(value))) break
773
+ if (value === '(' || value === '[' || value === '<') inner++
774
+ if (value === ')' || value === ']' || value === '>') inner--
775
+ stop++
776
+ if (inner < 0) break
777
+ }
778
+ for (const item of split(body.slice(i + 1, stop), ',')) {
779
+ const itemName = item[0]?.text ?? ''
780
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(itemName)) continue
781
+ if (item.some((entry) => entry.text === '(')) {
782
+ problem = problem ?? `enum ${name} has associated values, which cannot cross the boundary`
783
+ continue
784
+ }
785
+ if (item.some((entry) => entry.text === '=')) {
786
+ problem = problem ?? `enum ${name} has an explicit raw value, which cannot cross the boundary`
787
+ continue
788
+ }
789
+ cases.push({ name: itemName, value: backing === 'string' ? itemName : counter })
790
+ counter++
791
+ }
792
+ i = stop
793
+ }
794
+ return {
795
+ name,
796
+ token,
797
+ generic,
798
+ usable: problem === null,
799
+ problem: problem ?? '',
800
+ backing,
801
+ cases,
802
+ }
803
+ }
804
+
805
+ // a top-level `enum class Name` in kotlin. entries cross by name, so members
806
+ // after the entries never matter; anything but plain entries ends the list.
807
+ function collectKotlinEnum(
808
+ file: string,
809
+ body: Token[],
810
+ name: string,
811
+ token: Token
812
+ ): CollectedEnum {
813
+ const cases: { name: string; value: string | number }[] = []
814
+ let i = 0
815
+ const stopWords = new Set([
816
+ ';', 'fun', 'val', 'var', 'init', 'companion', 'constructor', 'object',
817
+ 'inner', 'abstract', 'open', 'override', 'lateinit', 'const', 'public',
818
+ 'private', 'protected', 'internal',
819
+ ])
820
+ while (i < body.length && !stopWords.has(body[i].text)) {
821
+ if (body[i].text === ',') { i++; continue }
822
+ // skip entry annotations: `@SerialName("n") North`
823
+ if (body[i].text === '@') {
824
+ i += 2
825
+ if (body[i]?.text === '(') i = close(file, body, i, '(', ')') + 1
826
+ continue
827
+ }
828
+ const entry = body[i].text
829
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry)) break
830
+ cases.push({ name: entry, value: entry })
831
+ i++
832
+ // skip constructor arguments and per-entry bodies: `North("n") { ... }`
833
+ while (i < body.length && (body[i].text === '(' || body[i].text === '{')) {
834
+ i = close(file, body, i, body[i].text, body[i].text === '(' ? ')' : '}') + 1
835
+ }
836
+ }
837
+ return {
838
+ name,
839
+ token,
840
+ generic: false,
841
+ usable: cases.length > 0,
842
+ problem: cases.length > 0 ? '' : `enum class ${name} has no entries to cross the boundary`,
843
+ backing: 'string',
844
+ cases,
845
+ }
846
+ }
847
+
848
+ // words that open a member declaration inside a data struct; the whole
849
+ // member is skipped, so a method body never reads as struct fields.
850
+ const STRUCT_MEMBER_WORDS = new Set([
851
+ 'init', 'deinit', 'func', 'subscript', 'struct', 'enum', 'class',
852
+ 'extension', 'object', 'static',
853
+ ])
854
+
855
+ // the stored properties of a collected swift struct. mirrors the imported
856
+ // view rules, but a data field is optional exactly when it carries a value:
857
+ // Codable fills a defaulted property itself.
858
+ function swiftStructFields(
859
+ file: string,
860
+ struct: CollectedStruct,
861
+ named: NamedTypes
862
+ ): NativeSourceStructField[] {
863
+ const fields: NativeSourceStructField[] = []
864
+ const body = struct.body
865
+ const end = body.length
866
+ let i = 0
867
+ let previous = 0
868
+ while (i < end) {
869
+ const text = body[i].text
870
+ if (text === '{') { i = close(file, body, i, '{', '}') + 1; previous = i; continue }
871
+ if (STRUCT_MEMBER_WORDS.has(text)) {
872
+ let scan = i + 1
873
+ let angle = 0
874
+ let round = 0
875
+ while (scan < end) {
876
+ const value = body[scan].text
877
+ if (value === '<') angle++
878
+ else if (value === '>') angle--
879
+ else if (value === '(') round++
880
+ else if (value === ')') round--
881
+ else if (value === '{' && angle === 0 && round === 0) break
882
+ else if (value === ';' && angle === 0 && round === 0) break
883
+ scan++
884
+ }
885
+ i = body[scan]?.text === '{' ? close(file, body, scan, '{', '}') + 1 : scan + 1
886
+ previous = i
887
+ continue
888
+ }
889
+ if (text !== 'let' && text !== 'var') { i++; continue }
890
+ const property = storedProperty(file, body, i, previous, end, `struct ${struct.name}`)
891
+ const { at, own, propName, typeTokens, valued } = property
892
+ i = property.resume
893
+ previous = i
894
+ if (property.computed || own.includes('static')) continue
895
+ const wrapper = own.findIndex((value, index) => own[index - 1] === '@' && !PLAIN_ATTRIBUTES.has(value))
896
+ if (wrapper >= 0) {
897
+ fail(file, at, `@${own[wrapper]} ${propName} in struct ${struct.name} is unsupported; cross a plain value instead`)
898
+ }
899
+ if (own.includes('private') || own.includes('fileprivate')) {
900
+ if (valued) continue
901
+ fail(file, at, `private ${propName} in struct ${struct.name} needs a value, or calls cannot supply it`)
902
+ }
903
+ if (typeTokens.length === 0) fail(file, at, `struct ${struct.name} field ${propName} needs an explicit type`)
904
+ const resolved = tsType(file, typeTokens, 'swift', named)
905
+ fields.push({
906
+ name: propName,
907
+ type: resolved.text,
908
+ nativeType: typeTokens.map((token) => token.text).join(''),
909
+ optional: valued,
910
+ wire: resolved.wire ?? undefined,
911
+ })
912
+ }
913
+ return fields
914
+ }
915
+
916
+ // the constructor parameters of a collected kotlin data class. a defaulted
917
+ // parameter stays optional: the generated glue embeds the default when the
918
+ // key is missing.
919
+ function kotlinStructFields(
920
+ file: string,
921
+ struct: CollectedStruct,
922
+ named: NamedTypes
923
+ ): NativeSourceStructField[] {
924
+ const fields: NativeSourceStructField[] = []
925
+ for (const part of struct.params) {
926
+ const keyword = part.findIndex((token) => token.text === 'val' || token.text === 'var')
927
+ if (keyword < 0) fail(file, part[0], `data class ${struct.name} field needs val or var`)
928
+ if (part.some((token) => token.text === 'vararg')) {
929
+ fail(file, part[0], `data class ${struct.name} field ${part[keyword + 1]?.text ?? ''} is vararg, which cannot cross the boundary`)
930
+ }
931
+ const fieldName = part[keyword + 1]?.text ?? ''
932
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
933
+ fail(file, part[keyword], `data class ${struct.name} has a field without a simple name`)
934
+ }
935
+ // the type runs to the default at depth zero: defaults may nest commas
936
+ // (`listOf(1, 2)`) and annotations may carry colons.
937
+ let colon = -1
938
+ let equals = -1
939
+ let depth = 0
940
+ for (let t = keyword + 2; t < part.length; t++) {
941
+ const value = part[t].text
942
+ if (value === '(' || value === '[' || value === '<') depth++
943
+ else if (value === ')' || value === ']' || value === '>') depth--
944
+ else if (depth === 0 && value === ':' && colon < 0) colon = t
945
+ else if (depth === 0 && value === '=' && equals < 0) equals = t
946
+ }
947
+ if (colon < 0) fail(file, part[keyword], `data class ${struct.name} field ${fieldName} needs an explicit type`)
948
+ const typeTokens = part.slice(colon + 1, equals < 0 ? part.length : equals)
949
+ const fallback = equals < 0 ? undefined : part.slice(equals + 1).map((token) => token.text).join('')
950
+ const resolved = tsType(file, typeTokens, 'kotlin', named)
951
+ fields.push({
952
+ name: fieldName,
953
+ type: resolved.text,
954
+ nativeType: typeTokens.map((token) => token.text).join(''),
955
+ optional: equals >= 0,
956
+ wire: resolved.wire ?? undefined,
957
+ fallback,
958
+ })
959
+ }
960
+ return fields
961
+ }
962
+
963
+ // walks every wire the contract recorded and parses the fields of each
964
+ // referenced struct. unreferenced declarations are never validated, so a
965
+ // view-only file keeps parsing exactly as before.
966
+ function finalizeNamedTypes(
967
+ file: string,
968
+ language: 'swift' | 'kotlin',
969
+ named: NamedTypes,
970
+ structs: CollectedStruct[],
971
+ enums: CollectedEnum[],
972
+ modules: NativeSourceModule[],
973
+ views: NativeSourceView[]
974
+ ): { structs: NativeSourceStruct[]; enums: NativeSourceEnum[] } {
975
+ const reached = new Set<string>()
976
+ const queue: string[] = []
977
+ const visitWire = (wire: NativeSourceWire | undefined): void => {
978
+ if (!wire) return
979
+ if (wire.kind === 'struct' || wire.kind === 'enum') {
980
+ if (!reached.has(wire.name)) {
981
+ reached.add(wire.name)
982
+ if (wire.kind === 'struct') queue.push(wire.name)
983
+ }
984
+ } else if (wire.kind === 'array') visitWire(wire.element)
985
+ else if (wire.kind === 'map') visitWire(wire.value)
986
+ else if (wire.kind === 'nullable') visitWire(wire.inner)
987
+ }
988
+ for (const module of modules) {
989
+ for (const method of module.methods) {
990
+ for (const param of method.parameters) visitWire(param.wire)
991
+ visitWire(method.resultWire)
992
+ for (const arg of method.subscription?.payload ?? []) visitWire(arg.wire)
993
+ }
994
+ }
995
+ for (const view of views) {
996
+ for (const prop of view.props) {
997
+ visitWire(prop.wire)
998
+ for (const arg of prop.callback ?? []) visitWire(arg.wire)
999
+ }
1000
+ }
1001
+ const byName = new Map(structs.map((entry) => [entry.name, entry]))
1002
+ const parsed = new Map<string, NativeSourceStructField[]>()
1003
+ while (queue.length > 0) {
1004
+ const name = queue.pop() as string
1005
+ if (parsed.has(name)) continue
1006
+ const decl = byName.get(name)
1007
+ if (!decl) continue
1008
+ const fields = language === 'swift'
1009
+ ? swiftStructFields(file, decl, named)
1010
+ : kotlinStructFields(file, decl, named)
1011
+ parsed.set(name, fields)
1012
+ for (const field of fields) visitWire(field.wire)
1013
+ }
1014
+ return {
1015
+ structs: structs
1016
+ .filter((entry) => reached.has(entry.name))
1017
+ .map((entry) => ({ name: entry.name, fields: parsed.get(entry.name) ?? [] })),
1018
+ enums: enums
1019
+ .filter((entry) => reached.has(entry.name))
1020
+ .map((entry) => ({ name: entry.name, backing: entry.backing, cases: entry.cases })),
1021
+ }
1022
+ }
1023
+
1024
+ // top-level `struct Name` / `enum Name` in swift, `data class Name` and
1025
+ // `enum class Name` in kotlin. nested declarations stay unknown to the
1026
+ // contract: only a file-level declaration crosses the boundary.
1027
+ function collectNamedTypes(
1028
+ file: string,
1029
+ list: Token[],
1030
+ language: 'swift' | 'kotlin',
1031
+ packageName: string | undefined
1032
+ ): { structs: CollectedStruct[]; enums: CollectedEnum[]; names: NamedTypes } {
1033
+ const structs: CollectedStruct[] = []
1034
+ const enums: CollectedEnum[] = []
1035
+ let depth = 0
1036
+ let i = 0
1037
+ while (i < list.length) {
1038
+ const text = list[i].text
1039
+ if (text === '{') { depth++; i++; continue }
1040
+ if (text === '}') { depth--; i++; continue }
1041
+ if (depth !== 0) { i++; continue }
1042
+ const isSwiftStruct = language === 'swift' && text === 'struct'
1043
+ const isSwiftEnum = language === 'swift' && text === 'enum'
1044
+ const isDataClass =
1045
+ language === 'kotlin' && text === 'class' && list[i - 1]?.text === 'data'
1046
+ const isKotlinEnum =
1047
+ language === 'kotlin' && text === 'class' && list[i - 1]?.text === 'enum'
1048
+ if (!isSwiftStruct && !isSwiftEnum && !isDataClass && !isKotlinEnum) { i++; continue }
1049
+ const name = list[i + 1]?.text
1050
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { i++; continue }
1051
+ const token = list[i + 1]
1052
+ const generic = list[i + 2]?.text === '<'
1053
+ if (isDataClass) {
1054
+ const dataClass = collectDataClass(file, list, i, name, token, generic, packageName)
1055
+ // advance past the constructor parameters; an optional body stays for
1056
+ // the main loop, so only the parameter list is skipped here.
1057
+ let scan = i + 2
1058
+ if (list[scan]?.text === '<') scan = close(file, list, scan, '<', '>') + 1
1059
+ while (['private', 'protected', 'public', 'internal', 'constructor'].includes(list[scan]?.text)) scan++
1060
+ i = list[scan]?.text === '(' ? close(file, list, scan, '(', ')') + 1 : i + 2
1061
+ if (dataClass) structs.push(dataClass)
1062
+ continue
1063
+ }
1064
+ // find the declaration body, skipping generics and inheritance
1065
+ let body = i + 2
1066
+ let angle = 0
1067
+ let round = 0
1068
+ while (body < list.length) {
1069
+ const value = list[body].text
1070
+ if (value === '<') angle++
1071
+ else if (value === '>') angle--
1072
+ else if (value === '(') round++
1073
+ else if (value === ')') round--
1074
+ else if (value === '{' && angle === 0 && round === 0) break
1075
+ body++
1076
+ }
1077
+ if (body >= list.length) { i++; continue }
1078
+ const end = close(file, list, body, '{', '}')
1079
+ const header = list.slice(i + 2, body)
1080
+ if (isSwiftStruct) {
1081
+ const inherits = header.map((entry) => entry.text)
1082
+ // a file-private struct hides from the generated glue in its own module
1083
+ const modifiers = list.slice(declarationStart(list, 0, i), i).map((entry) => entry.text)
1084
+ structs.push({
1085
+ name,
1086
+ token,
1087
+ generic,
1088
+ codable: inherits.includes('Codable') || (inherits.includes('Decodable') && inherits.includes('Encodable')),
1089
+ problem: modifiers.includes('private') || modifiers.includes('fileprivate')
1090
+ ? `struct ${name} is private to its file and cannot cross the boundary`
1091
+ : null,
1092
+ body: list.slice(body + 1, end),
1093
+ params: [],
1094
+ packageName: undefined,
1095
+ })
1096
+ } else {
1097
+ enums.push(
1098
+ language === 'swift'
1099
+ ? collectSwiftEnum(file, list.slice(body + 1, end), name, token, generic, header)
1100
+ : collectKotlinEnum(file, list.slice(body + 1, end), name, token)
1101
+ )
1102
+ }
1103
+ i = end + 1
1104
+ }
1105
+ return {
1106
+ structs,
1107
+ enums,
1108
+ names: {
1109
+ structs: new Map(
1110
+ structs.map((entry) => [entry.name, { codable: entry.codable, generic: entry.generic, problem: entry.problem }])
1111
+ ),
1112
+ enums: new Map(
1113
+ enums.map((entry) => [entry.name, { usable: entry.usable, problem: entry.problem }])
1114
+ ),
1115
+ },
1116
+ }
1117
+ }
1118
+
1119
+ // a top-level public @Composable fun, or null for any other top-level fun
1120
+ function composableView(
1121
+ file: string,
1122
+ list: Token[],
1123
+ keyword: number,
1124
+ floor: number,
1125
+ named: NamedTypes
1126
+ ): NativeSourceView | null {
1127
+ const modifiers = list.slice(declarationStart(list, floor, keyword), keyword).map((token) => token.text)
1128
+ if (!modifiers.some((value, index) => value === '@' && modifiers[index + 1] === 'Composable')) return null
1129
+ if (modifiers.includes('private') || modifiers.includes('internal')) return null
1130
+ const name = list[keyword + 1]?.text
1131
+ if (!name || !/^[A-Z][A-Za-z0-9_]*$/.test(name)) fail(file, list[keyword], 'an imported composable needs a capitalized name')
1132
+ if (list[keyword + 2]?.text !== '(') fail(file, list[keyword], 'generic or extension composables are unsupported')
1133
+ const closing = close(file, list, keyword + 2, '(', ')')
1134
+ if (list[closing + 1]?.text === ':') fail(file, list[closing + 1], `composable ${name} must return Unit`)
1135
+ const inner = list.slice(keyword + 3, closing)
1136
+ const props = inner.length === 0 ? [] : split(inner, ',').flatMap((part) => {
1137
+ if (part.length === 0) return []
1138
+ const prop = viewProp(file, part, named)
1139
+ return prop ? [prop] : []
1140
+ })
1141
+ return { name, props }
1142
+ }
1143
+
1144
+ export function nativeSourceContract(file: string, source: string): NativeSourceContract {
1145
+ const language = file.endsWith('.swift') ? 'swift' : file.endsWith('.kt') ? 'kotlin' : undefined
1146
+ if (!language) throw new Error(`${file}: expected a .swift or .kt source file`)
1147
+ const list = tokens(file, source)
1148
+ const packageToken = language === 'kotlin' ? list.findIndex((token) => token.text === 'package') : -1
1149
+ const packageName = packageToken < 0 ? undefined : list
1150
+ .slice(packageToken + 1)
1151
+ .filter((token) => token.line === list[packageToken].line)
1152
+ .map((token) => token.text)
1153
+ .join('')
1154
+ if (packageName && !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(packageName)) {
1155
+ fail(file, list[packageToken], `unsupported kotlin package ${packageName}`)
1156
+ }
1157
+ const collected = collectNamedTypes(file, list, language, packageName)
1158
+ const named = collected.names
1159
+ const modules: NativeSourceModule[] = []
1160
+ const views: NativeSourceView[] = []
1161
+ let defaultView = false
1162
+ let i = 0
1163
+ let previous = 0
1164
+ while (i < list.length) {
1165
+ const kind = list[i].text
1166
+ const declaration = language === 'swift' ? kind === 'class' || kind === 'struct' : kind === 'object'
1167
+ if (language === 'kotlin' && kind === 'fun') {
1168
+ const view = composableView(file, list, i, previous, named)
1169
+ if (view) {
1170
+ if (views.some((other) => other.name === view.name)) fail(file, list[i], `overloaded composable ${view.name} is unsupported`)
1171
+ views.push(view)
1172
+ }
1173
+ i++
1174
+ continue
1175
+ }
1176
+ if (!declaration) {
1177
+ if (kind === '{') { i = close(file, list, i, '{', '}') + 1; previous = i }
1178
+ else i++
1179
+ continue
1180
+ }
1181
+ const name = list[i + 1]?.text
1182
+ if (!name) fail(file, list[i], 'native type has no name')
1183
+ let body = i + 2
1184
+ while (body < list.length && list[body].text !== '{') body++
1185
+ if (body === list.length) fail(file, list[i], 'native type has no body')
1186
+ const end = close(file, list, body, '{', '}')
1187
+ const prefix = list.slice(previous, i).map((token) => token.text)
1188
+ if (language === 'swift' && prefix.some((value, index) => value === '@' && prefix[index + 1] === 'main')) {
1189
+ defaultView = true
1190
+ }
1191
+ const inherits = list.slice(i + 2, body).map((token) => token.text)
1192
+ if (inherits.includes(language === 'swift' ? 'PeachModule' : 'OneModule')) {
1193
+ modules.push({ name, methods: methods(file, list, body, end, language, named) })
1194
+ }
1195
+ const view = language === 'swift' ? swiftView(file, list, i, previous, body, end, named) : null
1196
+ if (view) views.push(view)
1197
+ i = end + 1
1198
+ previous = i
1199
+ }
1200
+ const finalized = finalizeNamedTypes(file, language, named, collected.structs, collected.enums, modules, views)
1201
+ const structs = finalized.structs
1202
+ const enums = finalized.enums
1203
+ const canonical = JSON.stringify({ language, packageName, modules, views, structs, enums, defaultView })
1204
+ const hash = createHash('sha256').update(canonical).digest('hex')
1205
+ const declarations = structs.map((struct) =>
1206
+ `export interface ${struct.name} {${struct.fields.map((field) => ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`).join('')} }`
1207
+ )
1208
+ declarations.push(...enums.map((type) =>
1209
+ `export type ${type.name} = ${type.cases.map((entry) => typeof entry.value === 'string' ? JSON.stringify(entry.value) : String(entry.value)).join(' | ')}`
1210
+ ))
1211
+ declarations.push(...modules.map((module) => [
1212
+ `export declare const ${module.name}: {`,
1213
+ ...module.methods.map((method) =>
1214
+ method.subscription
1215
+ ? ` ${method.name}(listener: (${method.subscription.payload.map((arg, index) => `value${index}: ${arg.type}`).join(', ')}) => void): () => void`
1216
+ : ` ${method.name}(${method.parameters.map((param) => `${param.name}: ${param.type}`).join(', ')}): Promise<${method.result}>`
1217
+ ),
1218
+ '}',
1219
+ ].join('\n')))
1220
+ declarations.push(...views.map((view) =>
1221
+ `export declare function ${view.name}(props: {${view.props.map((prop) => ` ${prop.name}${prop.optional ? '?' : ''}: ${prop.type}`).join(';')} }): ReactElement`
1222
+ ))
1223
+ if (defaultView) {
1224
+ declarations.unshift("export default function SwiftPackage(props: Record<string, unknown>): ReactElement")
1225
+ }
1226
+ if (defaultView || views.length > 0) declarations.unshift("import type { ReactElement } from 'react'")
1227
+ const declaration = `${GENERATED_HEADER}${declarations.join('\n\n')}\n`
1228
+ return { language, packageName, modules, views, structs, enums, defaultView, hash, declaration }
1229
+ }
1230
+
1231
+ export function kotlinSourceId(root: string, file: string): string {
1232
+ const path = relative(root, file).replaceAll('\\', '/')
1233
+ if (path.startsWith('../') || !path.endsWith('.kt')) {
1234
+ throw new Error(`${file} is not a kotlin source under ${root}`)
1235
+ }
1236
+ return `K_${createHash('sha256').update(path).digest('hex').slice(0, 16)}`
1237
+ }
1238
+
1239
+ export function swiftPodManifest(file: string, source: string): {
1240
+ languageMode: 5 | 6
1241
+ mainActorIsolation: boolean
1242
+ } {
1243
+ const list = tokens(file, source)
1244
+ const modes = new Set<number>()
1245
+ let mainActorIsolation = false
1246
+ for (let i = 0; i < list.length; i++) {
1247
+ if (list[i].text === '.' && list[i + 1]?.text === 'package' && list[i + 2]?.text === '(') {
1248
+ fail(file, list[i], 'SwiftPM package dependencies are unsupported by One prebuild')
1249
+ }
1250
+ if (list[i].text === '.' && list[i + 1]?.text === 'swiftLanguageMode' && list[i + 2]?.text === '(') {
1251
+ const mode = /^v(\d+)$/.exec(list[i + 4]?.text ?? '')
1252
+ if (list[i + 3]?.text !== '.' || !mode) fail(file, list[i], 'unsupported swift language mode')
1253
+ modes.add(Number(mode[1]))
1254
+ }
1255
+ if (list[i].text === 'swiftLanguageModes' && list[i + 1]?.text === ':') {
1256
+ const opening = i + 2
1257
+ if (list[opening]?.text !== '[') fail(file, list[i], 'unsupported swift language modes')
1258
+ const ending = close(file, list, opening, '[', ']')
1259
+ for (let j = opening; j < ending; j++) {
1260
+ if (list[j].text === '.' && /^v\d+$/.test(list[j + 1]?.text ?? '')) {
1261
+ modes.add(Number(list[j + 1].text.slice(1)))
1262
+ }
1263
+ }
1264
+ }
1265
+ if (list[i].text === '.' && list[i + 1]?.text === 'defaultIsolation' && list[i + 2]?.text === '(') {
1266
+ if (list[i + 3]?.text !== 'MainActor') fail(file, list[i], 'unsupported swift default isolation')
1267
+ mainActorIsolation = true
1268
+ }
1269
+ }
1270
+ if (modes.size > 1) throw new Error(`${file}: One pod needs one swift language mode; found ${[...modes].join(', ')}`)
1271
+ const languageMode = modes.values().next().value ?? 5
1272
+ if (languageMode !== 5 && languageMode !== 6) {
1273
+ throw new Error(`${file}: unsupported swift language mode v${languageMode}`)
1274
+ }
1275
+ return { languageMode, mainActorIsolation }
1276
+ }
1277
+
1278
+ export function writeNativeSourceDeclaration(file: string): NativeSourceContract {
1279
+ const contract = nativeSourceContract(file, readFileSync(file, 'utf8'))
1280
+ const extension = contract.language === 'swift' ? '.swift' : '.kt'
1281
+ const declaration = join(dirname(file), `${basename(file, extension)}.d${extension}.ts`)
1282
+ if (contract.modules.length === 0 && contract.views.length === 0 && !contract.defaultView) {
1283
+ if (existsSync(declaration) && readFileSync(declaration, 'utf8').startsWith(GENERATED_HEADER)) {
1284
+ rmSync(declaration, { force: true })
1285
+ }
1286
+ return contract
1287
+ }
1288
+ writeGeneratedFile(declaration, contract.declaration)
1289
+ return contract
1290
+ }
1291
+
1292
+ export function writeNativeSourceDeclarations(root: string): NativeSourceContract[] {
1293
+ const contracts: NativeSourceContract[] = []
1294
+ const visit = (dir: string) => {
1295
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1296
+ if (entry.name.startsWith('.') || SKIP_SOURCE_DIRS.has(entry.name)) continue
1297
+ const file = join(dir, entry.name)
1298
+ if (entry.isDirectory()) visit(file)
1299
+ else if (entry.isFile() && /\.(swift|kt)$/.test(entry.name) && entry.name !== 'Package.swift') {
1300
+ contracts.push(writeNativeSourceDeclaration(file))
1301
+ }
1302
+ }
1303
+ }
1304
+ visit(root)
1305
+ for (const packageDir of swiftPackageDirectories(root).values()) {
1306
+ writeSwiftPackageArtifacts(packageDir)
1307
+ }
1308
+ return contracts
1309
+ }
1310
+
1311
+ export function writeSwiftPackageArtifacts(packageDir: string): SwiftPackageArtifacts {
1312
+ const contracts: NativeSourceContract[] = []
1313
+ const files: Record<string, string[]> = {}
1314
+ const modules: Record<string, string[]> = {}
1315
+ const views: Record<string, string[]> = {}
1316
+ const defaultViewFiles: string[] = []
1317
+ const visit = (dir: string) => {
1318
+ const entries = readdirSync(dir, { withFileTypes: true })
1319
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
1320
+ for (const entry of entries) {
1321
+ if (entry.name.startsWith('.') || SKIP_SOURCE_DIRS.has(entry.name)) continue
1322
+ const file = join(dir, entry.name)
1323
+ if (entry.isDirectory()) visit(file)
1324
+ else if (entry.isFile() && entry.name.endsWith('.swift') && entry.name !== 'Package.swift') {
1325
+ const contract = writeNativeSourceDeclaration(file)
1326
+ contracts.push(contract)
1327
+ const name = relative(packageDir, file).replaceAll('\\', '/')
1328
+ files[name] = [...contract.modules, ...contract.views].map((entry) => entry.name)
1329
+ for (const module of contract.modules) modules[module.name] = module.methods.map((method) => method.name)
1330
+ for (const view of contract.views) views[view.name] = view.props.map((prop) => prop.name)
1331
+ if (contract.defaultView) defaultViewFiles.push(name)
1332
+ }
1333
+ }
1334
+ }
1335
+ visit(packageDir)
1336
+ const packageId = swiftPackageId(packageDir)
1337
+ const rendered = renderSwiftSourceGlue(packageId, contracts)
1338
+ const descriptor = join(packageDir, '.one-native-source.json')
1339
+ const glue = join(packageDir, '.one-native-source.swift.txt')
1340
+ const exported = contracts.some((contract) => contract.modules.length > 0 || contract.views.length > 0)
1341
+ if (exported) {
1342
+ const json = `${JSON.stringify({ generatedBy: 'one typegen', packageId, hash: rendered.hash, files, modules, views, defaultViewFiles }, null, 2)}\n`
1343
+ writeGeneratedFile(descriptor, json)
1344
+ writeGeneratedFile(glue, rendered.source)
1345
+ } else if (existsSync(descriptor) && readFileSync(descriptor, 'utf8').includes('"generatedBy": "one typegen"')) {
1346
+ rmSync(descriptor, { force: true })
1347
+ rmSync(glue, { force: true })
1348
+ }
1349
+ return { packageId, hash: rendered.hash, files, modules, views, defaultViewFiles, contracts, glue: exported ? rendered.source : null }
1350
+ }
1351
+
1352
+ export function renderSwiftSourceGlue(
1353
+ packageId: string,
1354
+ contracts: NativeSourceContract[]
1355
+ ): { hash: string; source: string } {
1356
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(packageId)) {
1357
+ throw new Error(`invalid swift package id ${packageId}`)
1358
+ }
1359
+ for (const contract of contracts) {
1360
+ if (contract.language !== 'swift') throw new Error('swift glue received a kotlin contract')
1361
+ }
1362
+ const modules = contracts.flatMap((contract) => contract.modules)
1363
+ const views = contracts.flatMap((contract) => contract.views)
1364
+ const names = new Set<string>()
1365
+ for (const { name } of [...modules, ...views]) {
1366
+ if (names.has(name)) throw new Error(`swift package ${packageId} declares ${name} twice`)
1367
+ names.add(name)
1368
+ }
1369
+ const hash = createHash('sha256')
1370
+ .update(JSON.stringify({ packageId, modules, views }))
1371
+ .digest('hex')
1372
+ const hasMain = contracts.some((contract) => contract.defaultView)
1373
+ const bodyName = `OneNativeSourceBody_${packageId}`
1374
+ const className = `OneNativeSource_${packageId}`
1375
+ const viewsName = `OneNativeSourceViews_${packageId}`
1376
+ const renderCase = (method: NativeSourceMethod, module: NativeSourceModule): string[] => {
1377
+ const subscription = method.subscription
1378
+ if (subscription) {
1379
+ const channel = `OneNativeSource.${packageId}.${module.name}.${method.name}`
1380
+ const closureArgs = subscription.payload.map((arg, index) => `a${index}: ${arg.nativeType}`).join(', ')
1381
+ const emitArgs = subscription.payload.map((_, index) => `a${index}`).join(', ')
1382
+ const prefix = `${method.throws ? 'try ' : ''}${method.async ? 'await ' : ''}`
1383
+ return [
1384
+ ` case ${JSON.stringify(method.name)}:`,
1385
+ ` guard values.isEmpty else { throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "${packageId}.${module.name}.${method.name} subscribes; it takes no arguments"]) }`,
1386
+ ` let channel = ${JSON.stringify(channel)}`,
1387
+ ` ${prefix}${module.name}Instance.${method.name}({ ${closureArgs ? `(${closureArgs}) in ` : ''}postEvent(channel, args([${emitArgs}])) })`,
1388
+ ' return "null"',
1389
+ ]
1390
+ }
1391
+ const lines = [
1392
+ ` case ${JSON.stringify(method.name)}:`,
1393
+ ` guard values.count == ${method.parameters.length} else { throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "${packageId}.${module.name}.${method.name} expects ${method.parameters.length} arguments"]) }`,
1394
+ ]
1395
+ method.parameters.forEach((parameter, index) => {
1396
+ lines.push(` let arg${index} = try decode(values[${index}], as: ${parameter.nativeType}.self)`)
1397
+ })
1398
+ const argumentsText = method.parameters.map((parameter, index) =>
1399
+ `${parameter.label === '_' ? '' : `${parameter.label}: `}arg${index}`
1400
+ ).join(', ')
1401
+ const prefix = `${method.throws ? 'try ' : ''}${method.async ? 'await ' : ''}`
1402
+ const call = `${module.name}Instance.${method.name}(${argumentsText})`
1403
+ if (method.result === 'void') {
1404
+ lines.push(` ${prefix}${call}`, ' return "null"')
1405
+ } else {
1406
+ lines.push(` let result = ${prefix}${call}`)
1407
+ if (method.nativeResult === 'Int') {
1408
+ lines.push(' guard abs(Double(result)) <= 9_007_199_254_740_991 else { throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "result exceeds the JavaScript safe integer range"]) }')
1409
+ }
1410
+ lines.push(' return try encode(result)')
1411
+ }
1412
+ return lines
1413
+ }
1414
+ const notLinked = ' throw NSError(domain: "OneNativeSource", code: 1, userInfo: [NSLocalizedDescriptionKey: "native module \\(module) is not linked; rebuild the app"])'
1415
+ const renderCall = (): string[] => modules.length === 0 ? [
1416
+ ' static func call(_ module: String, _ method: String, _ argsJson: String) async throws -> String {',
1417
+ notLinked,
1418
+ ' }',
1419
+ ] : [
1420
+ ' static func call(_ module: String, _ method: String, _ argsJson: String) async throws -> String {',
1421
+ ' guard let values = try JSONSerialization.jsonObject(with: Data(argsJson.utf8)) as? [Any] else {',
1422
+ ' throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "arguments must be a JSON array"])',
1423
+ ' }',
1424
+ ' switch module {',
1425
+ ...modules.flatMap((module) => [
1426
+ ` case ${JSON.stringify(`${packageId}.${module.name}`)}:`,
1427
+ ' switch method {',
1428
+ ...module.methods.flatMap((method) => renderCase(method, module)),
1429
+ ' default: throw NSError(domain: "OneNativeSource", code: 1, userInfo: [NSLocalizedDescriptionKey: "native method \\(module).\\(method) is not linked; rebuild the app"])',
1430
+ ' }',
1431
+ ]),
1432
+ ' default:',
1433
+ ` ${notLinked}`,
1434
+ ' }',
1435
+ ' }',
1436
+ ]
1437
+ // builds an imported view from the host's json props; a callback reports its
1438
+ // arguments to the host as a json array.
1439
+ const renderViews = (): string[] => [
1440
+ ' private static func missing(_ path: String) throws -> Any {',
1441
+ ' throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "\\(path): required"])',
1442
+ ' }',
1443
+ ' static func view(_ name: String, _ propsJson: String, _ emit: @escaping (String, String) -> Void) throws -> AnyView {',
1444
+ ' guard let props = try JSONSerialization.jsonObject(with: Data(propsJson.utf8)) as? [String: Any] else {',
1445
+ ' throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "props must be a JSON object"])',
1446
+ ' }',
1447
+ ' switch name {',
1448
+ ...views.flatMap((view) => [
1449
+ ` case ${JSON.stringify(view.name)}:`,
1450
+ ` return AnyView(${view.name}(`,
1451
+ ...view.props.map((prop, index) => {
1452
+ const comma = index === view.props.length - 1 ? '' : ','
1453
+ const key = JSON.stringify(prop.name)
1454
+ if (prop.callback) {
1455
+ const params = prop.callback.map((arg, n) => `a${n}: ${arg.nativeType}`).join(', ')
1456
+ const lambda = `{ (${params}) -> Void in emit(${key}, args([${prop.callback.map((_, n) => `a${n}`).join(', ')}])) }`
1457
+ // the host sends true for each callback the element passed
1458
+ return ` ${prop.name}: ${prop.optional ? `(props[${key}] as? Bool) == true ? ${lambda} : nil` : lambda}${comma}`
1459
+ }
1460
+ const input = prop.optional ? `props[${key}] ?? NSNull()` : `props[${key}] ?? missing(${JSON.stringify(`props.${prop.name}`)})`
1461
+ return ` ${prop.name}: try decode(${input}, as: ${prop.nativeType}.self)${comma}`
1462
+ }),
1463
+ ' ))',
1464
+ ]),
1465
+ ' default: throw NSError(domain: "OneNativeSource", code: 1, userInfo: [NSLocalizedDescriptionKey: "swift view \\(name) is not linked; rebuild the app"])',
1466
+ ' }',
1467
+ ' }',
1468
+ ]
1469
+ const source = [
1470
+ 'import Foundation',
1471
+ 'import SwiftUI',
1472
+ '',
1473
+ `@MainActor public enum ${bodyName} {`,
1474
+ ` public static let hash = ${JSON.stringify(hash)}`,
1475
+ '#if os(WASI)',
1476
+ ' private struct Request: Decodable { let module: String; let method: String; let argsJson: String; let contractHash: String }',
1477
+ ' private struct Reply: Encodable { let ok: Bool; let value: String?; let error: String?; let pending: Int32? }',
1478
+ ' private static var output: [UInt8] = []',
1479
+ ' private static var finished: [Int32: Reply] = [:]',
1480
+ ' private static var nextCall: Int32 = 0',
1481
+ ' // peach runs tasks inline, so a call finishes before its task launch returns',
1482
+ ' // unless it waits on the clock; then it answers pending and the host polls',
1483
+ ' // it after running the timers.',
1484
+ ' static func callWasm(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {',
1485
+ ' let id = nextCall',
1486
+ ' nextCall &+= 1',
1487
+ ' do {',
1488
+ ' guard let payload, length >= 0 else { throw NSError(domain: "OneNativeSource", code: 2, userInfo: [NSLocalizedDescriptionKey: "missing call payload"]) }',
1489
+ ' let request = try JSONDecoder().decode(Request.self, from: Data(bytes: payload, count: Int(length)))',
1490
+ ' guard request.contractHash == hash else { throw NSError(domain: "OneNativeSource", code: 4, userInfo: [NSLocalizedDescriptionKey: "native module \\(request.module) changed; rebuild the app"]) }',
1491
+ ' Task {',
1492
+ ' do {',
1493
+ ' finished[id] = Reply(ok: true, value: try await call(request.module, request.method, request.argsJson), error: nil, pending: nil)',
1494
+ ' } catch {',
1495
+ ' // the native hosts name the method on everything a call throws',
1496
+ ' finished[id] = Reply(ok: false, value: nil, error: "\\(request.module).\\(request.method): \\(error.localizedDescription)", pending: nil)',
1497
+ ' }',
1498
+ ' }',
1499
+ ' } catch {',
1500
+ ' finished[id] = Reply(ok: false, value: nil, error: error.localizedDescription, pending: nil)',
1501
+ ' }',
1502
+ ' return pollWasm(id)',
1503
+ ' }',
1504
+ ' static func pollWasm(_ id: Int32) -> UnsafeMutablePointer<UInt8>? {',
1505
+ ' let reply = finished.removeValue(forKey: id) ?? Reply(ok: true, value: nil, error: nil, pending: id)',
1506
+ ' let bytes = (try? JSONEncoder().encode(reply)) ?? Data("{\\"ok\\":false,\\"error\\":\\"encoding failed\\"}".utf8)',
1507
+ ' let count = UInt32(bytes.count)',
1508
+ ' output = [UInt8(count & 0xff), UInt8((count >> 8) & 0xff), UInt8((count >> 16) & 0xff), UInt8(count >> 24)] + Array(bytes)',
1509
+ ' return output.withUnsafeMutableBufferPointer { $0.baseAddress }',
1510
+ ' }',
1511
+ ' private struct Event: Encodable { let channel: String; let args: String }',
1512
+ ' private static var pendingEvents: [Event] = []',
1513
+ ' // under wasi there is no javascript runtime, so posted events queue',
1514
+ ' // here and the peach host drains them with peach_poll_events, using',
1515
+ ' // the same length-prefixed framing as pollWasm.',
1516
+ ' static func pollEvents() -> UnsafeMutablePointer<UInt8>? {',
1517
+ ' let events = pendingEvents',
1518
+ ' pendingEvents = []',
1519
+ ' let bytes = (try? JSONEncoder().encode(events)) ?? Data("[]".utf8)',
1520
+ ' let count = UInt32(bytes.count)',
1521
+ ' output = [UInt8(count & 0xff), UInt8((count >> 8) & 0xff), UInt8((count >> 16) & 0xff), UInt8(count >> 24)] + Array(bytes)',
1522
+ ' return output.withUnsafeMutableBufferPointer { $0.baseAddress }',
1523
+ ' }',
1524
+ '#endif',
1525
+ ...modules.map((module) => ` private static let ${module.name}Instance = ${module.name}()`),
1526
+ // dates cross as ISO strings in both directions; the fractional reader
1527
+ // runs first because javascript always writes milliseconds.
1528
+ ' private nonisolated static let isoDate = Date.ISO8601FormatStyle()',
1529
+ ' private nonisolated static let isoDateFractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true)',
1530
+ ' private nonisolated static func decodeDate(_ decoder: Decoder) throws -> Date {',
1531
+ ' let container = try decoder.singleValueContainer()',
1532
+ ' let text = try container.decode(String.self)',
1533
+ ' if let date = (try? isoDateFractional.parse(text)) ?? (try? isoDate.parse(text)) { return date }',
1534
+ ' throw DecodingError.dataCorruptedError(in: container, debugDescription: "expected ISO date")',
1535
+ ' }',
1536
+ ' private static func decode<T: Decodable>(_ value: Any, as type: T.Type) throws -> T {',
1537
+ ' let data = try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed])',
1538
+ ' let decoder = JSONDecoder()',
1539
+ ' decoder.dateDecodingStrategy = .custom { try decodeDate($0) }',
1540
+ ' decoder.dataDecodingStrategy = .base64',
1541
+ ' return try decoder.decode(T.self, from: data)',
1542
+ ' }',
1543
+ ' private static func encode(_ value: any Encodable) throws -> String {',
1544
+ ' let encoder = JSONEncoder()',
1545
+ ' encoder.dateEncodingStrategy = .iso8601',
1546
+ ' encoder.dataEncodingStrategy = .base64',
1547
+ ' return String(decoding: try encoder.encode(value), as: UTF8.self)',
1548
+ ' }',
1549
+ ' private static func encodeAny(_ value: any Encodable) -> String {',
1550
+ ' (try? encode(value)) ?? "null"',
1551
+ ' }',
1552
+ ' // subscribe cases and view callbacks both encode callback arguments',
1553
+ ' // as a json array, so this helper stays outside the view-only glue.',
1554
+ ' private static func args(_ values: [any Encodable]) -> String {',
1555
+ ' "[" + values.map { encodeAny($0) }.joined(separator: ",") + "]"',
1556
+ ' }',
1557
+ ' // native events post over the one event bridge on device; under wasi',
1558
+ ' // they queue for the peach host, which drains them with peach_poll_events.',
1559
+ ' private static func postEvent(_ channel: String, _ args: String) {',
1560
+ ' #if os(WASI)',
1561
+ ' pendingEvents.append(Event(channel: channel, args: args))',
1562
+ ' #else',
1563
+ ' OneNativeSourceEmitter.send(channel, args)',
1564
+ ' #endif',
1565
+ ' }',
1566
+ ...renderCall(),
1567
+ ...(views.length > 0 ? renderViews() : []),
1568
+ '}',
1569
+ '',
1570
+ '#if os(WASI)',
1571
+ '@_expose(wasm, "peach_call")',
1572
+ '@_cdecl("peach_call")',
1573
+ `@MainActor public func peach_call(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? { ${bodyName}.callWasm(payload, length) }`,
1574
+ '@_expose(wasm, "peach_call_poll")',
1575
+ '@_cdecl("peach_call_poll")',
1576
+ `@MainActor public func peach_call_poll(_ id: Int32) -> UnsafeMutablePointer<UInt8>? { ${bodyName}.pollWasm(id) }`,
1577
+ ' // the host drains queued native events with this export, polling it',
1578
+ ' // after running timers like it polls pending calls.',
1579
+ '@_expose(wasm, "peach_poll_events")',
1580
+ '@_cdecl("peach_poll_events")',
1581
+ `@MainActor public func peach_poll_events() -> UnsafeMutablePointer<UInt8>? { ${bodyName}.pollEvents() }`,
1582
+ // peach registers the typed views once per instance: the package's own
1583
+ // @main, if any, stays its default view, as on device
1584
+ ...(views.length > 0 ? [
1585
+ '@_expose(wasm, "peach_register_views")',
1586
+ '@_cdecl("peach_register_views")',
1587
+ '@MainActor public func peach_register_views() {',
1588
+ ' PeachTypedViews.register { name, contractHash, propsJson, emit in',
1589
+ ` guard contractHash == ${bodyName}.hash else { return AnyView(Text("swift view ${packageId}.\\(name) changed; rebuild the app").foregroundStyle(.red)) }`,
1590
+ ` do { return try ${bodyName}.view(name, propsJson, emit) } catch { return AnyView(Text("\\(name): \\(error.localizedDescription)").foregroundStyle(.red)) }`,
1591
+ ' }',
1592
+ '}',
1593
+ ] : []),
1594
+ ...(!hasMain ? [
1595
+ `@main struct OneNativeSourceMain_${packageId}: App {`,
1596
+ ' var body: some Scene { WindowGroup { EmptyView() } }',
1597
+ '}',
1598
+ ] : []),
1599
+ '#endif',
1600
+ '',
1601
+ // the dispatch class answers nitro on device; wasi uses the peach_call
1602
+ // exports above, and its sdk mirrors objectivec, so gate it off.
1603
+ '#if canImport(ObjectiveC) && !os(WASI)',
1604
+ `@objc(${className}) @MainActor public final class ${className}: NSObject, OneNativeSourceDispatch {`,
1605
+ ' public required override init() {}',
1606
+ ` public var contractHash: String { ${bodyName}.hash }`,
1607
+ ` public func call(_ module: String, _ method: String, _ argsJson: String) async throws -> String { try await ${bodyName}.call(module, method, argsJson) }`,
1608
+ '}',
1609
+ ...(views.length > 0 ? [
1610
+ `@objc(${viewsName}) @MainActor public final class ${viewsName}: NSObject, OneNativeSourceViewDispatch {`,
1611
+ ' public required override init() {}',
1612
+ ` public var contractHash: String { ${bodyName}.hash }`,
1613
+ ` public func view(_ name: String, _ propsJson: String, _ emit: @escaping (String, String) -> Void) throws -> AnyView { try ${bodyName}.view(name, propsJson, emit) }`,
1614
+ '}',
1615
+ ] : []),
1616
+ '#endif',
1617
+ '',
1618
+ ].join('\n')
1619
+ return { hash, source }
1620
+ }
1621
+
1622
+ export function renderKotlinSourceGlue(
1623
+ sourceId: string,
1624
+ contract: NativeSourceContract
1625
+ ): { hash: string; source: string } {
1626
+ if (contract.language !== 'kotlin') throw new Error('kotlin glue received a swift contract')
1627
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(sourceId)) throw new Error(`invalid kotlin source id ${sourceId}`)
1628
+ const hash = createHash('sha256').update(JSON.stringify({ sourceId, contract: contract.hash })).digest('hex')
1629
+ const structsByName = new Map(contract.structs.map((entry) => [entry.name, entry]))
1630
+ const enumsByName = new Map(contract.enums.map((entry) => [entry.name, entry]))
1631
+ const qualified = (name: string) => contract.packageName ? `${contract.packageName}.${name}` : name
1632
+ const isDateType = (type: string) => type === 'Date' || type === 'java.util.Date'
1633
+ // kotlin/wasm cannot resolve the jvm/android/event-host symbols, so a
1634
+ // contract that never mentions dates, bytes or subscriptions must not
1635
+ // reference them. every struct helper is always emitted, so a flat scan
1636
+ // over all type positions already covers types nested in other structs.
1637
+ const mentions = (type: string, match: (leaf: string) => boolean): boolean => {
1638
+ let rest = type
1639
+ while (rest.endsWith('?')) rest = rest.slice(0, -1)
1640
+ if (rest.startsWith('List<') && rest.endsWith('>')) return mentions(rest.slice(5, -1), match)
1641
+ if (rest.startsWith('Map<String,') && rest.endsWith('>')) return mentions(rest.slice(11, -1), match)
1642
+ return match(rest)
1643
+ }
1644
+ const nativeTypes = [
1645
+ ...contract.modules.flatMap((module) =>
1646
+ module.methods.flatMap((method) => [
1647
+ ...method.parameters.map((parameter) => parameter.nativeType),
1648
+ method.nativeResult,
1649
+ ...(method.subscription?.payload.map((arg) => arg.nativeType) ?? []),
1650
+ ])
1651
+ ),
1652
+ ...contract.structs.flatMap((struct) => struct.fields.map((field) => field.nativeType)),
1653
+ ...contract.views.flatMap((view) =>
1654
+ view.props.flatMap((prop) => [
1655
+ prop.nativeType,
1656
+ ...(prop.callback?.map((arg) => arg.nativeType) ?? []),
1657
+ ])
1658
+ ),
1659
+ ]
1660
+ const needsEvents = contract.modules.some((module) =>
1661
+ module.methods.some((method) => method.subscription)
1662
+ )
1663
+ const needsDate = nativeTypes.some((type) => mentions(type, isDateType))
1664
+ const needsData = nativeTypes.some((type) => mentions(type, (leaf) => leaf === 'ByteArray'))
1665
+ const value = (type: string, input: string, path: string): string => {
1666
+ if (type.endsWith('?')) {
1667
+ // bind the input once so kotlin smart casts it to Any past the null check.
1668
+ return `(${input}).let { raw -> if (raw == null || raw == JSONObject.NULL) null else ${value(type.slice(0, -1), 'raw', path)} }`
1669
+ }
1670
+ if (type === 'String') return `readString(${input}, ${path})`
1671
+ if (type === 'Boolean') return `readBoolean(${input}, ${path})`
1672
+ if (type === 'Double') return `readDouble(${input}, ${path})`
1673
+ if (type === 'Float') return `readDouble(${input}, ${path}).toFloat()`
1674
+ if (type === 'Int') return `readInt(${input}, ${path})`
1675
+ if (type === 'Long') return `readLong(${input}, ${path})`
1676
+ // dates cross as ISO strings, bytes as base64; structs and enums read
1677
+ // through the generated helpers below.
1678
+ if (isDateType(type)) return `readDate(${input}, ${path})`
1679
+ if (type === 'ByteArray') return `readData(${input}, ${path})`
1680
+ if (structsByName.has(type)) return `read${type}(${input}, ${path})`
1681
+ if (enumsByName.has(type)) return `read${type}(${input}, ${path})`
1682
+ if (type.startsWith('List<') && type.endsWith('>')) {
1683
+ return `readList(${input}, ${path}) { item, itemPath -> ${value(type.slice(5, -1), 'item', 'itemPath')} }`
1684
+ }
1685
+ if (type.startsWith('Map<String,') && type.endsWith('>')) {
1686
+ return `readMap(${input}, ${path}) { item, itemPath -> ${value(type.slice(11, -1), 'item', 'itemPath')} }`
1687
+ }
1688
+ throw new Error(`unsupported kotlin glue type ${type}`)
1689
+ }
1690
+ // an encoded value expression for a result or callback argument. primitives
1691
+ // keep the historical JSONObject.wrap shape; every other shape encodes
1692
+ // through its helper. the expression is never a raw null: missing values
1693
+ // encode as JSONObject.NULL so the key survives the round trip.
1694
+ const encode = (type: string, accessor: string): string => {
1695
+ if (type.endsWith('?')) {
1696
+ return `(${accessor}?.let { ${encode(type.slice(0, -1), 'it')} } ?: JSONObject.NULL)`
1697
+ }
1698
+ if (isDateType(type)) return `dateToString(${accessor})`
1699
+ if (type === 'ByteArray') return `android.util.Base64.encodeToString(${accessor}, android.util.Base64.NO_WRAP)`
1700
+ if (structsByName.has(type)) return `encode${type}(${accessor})`
1701
+ if (enumsByName.has(type)) return `${accessor}.name`
1702
+ if (type.startsWith('List<') && type.endsWith('>')) {
1703
+ return `JSONArray().apply { for (item in ${accessor}) put(${encode(type.slice(5, -1), 'item')}) }`
1704
+ }
1705
+ if (type.startsWith('Map<String,') && type.endsWith('>')) {
1706
+ return `JSONObject().apply { for ((key, entry) in ${accessor}) put(key, ${encode(type.slice(11, -1), 'entry')}) }`
1707
+ }
1708
+ return `JSONObject.wrap(${accessor})`
1709
+ }
1710
+ const structHelpers = contract.structs.flatMap((struct) => [
1711
+ `private fun read${struct.name}(value: Any, path: String): ${qualified(struct.name)} {`,
1712
+ ' val obj = value as? JSONObject ?: error("$path: expected object")',
1713
+ ` return ${qualified(struct.name)}(`,
1714
+ ...struct.fields.map((field) => {
1715
+ const fieldPath = `"$path.${field.name}"`
1716
+ const required = `error("$path.${field.name}: required")`
1717
+ const input = field.optional
1718
+ ? `(if (obj.has(${JSON.stringify(field.name)})) obj.get(${JSON.stringify(field.name)}) else ${field.fallback ?? required})`
1719
+ : `(obj.opt(${JSON.stringify(field.name)}) ?: ${required})`
1720
+ return ` ${field.name} = ${value(field.nativeType, input, fieldPath)},`
1721
+ }),
1722
+ ' )',
1723
+ '}',
1724
+ `private fun encode${struct.name}(value: ${qualified(struct.name)}): JSONObject {`,
1725
+ ' return JSONObject()',
1726
+ ...struct.fields.map((field) =>
1727
+ ` .put(${JSON.stringify(field.name)}, ${encode(field.nativeType, `value.${field.name}`)})`
1728
+ ),
1729
+ '}',
1730
+ ])
1731
+ const enumHelpers = contract.enums.flatMap((entry) => [
1732
+ `private fun read${entry.name}(value: Any, path: String): ${qualified(entry.name)} {`,
1733
+ ' val text = readString(value, path)',
1734
+ ` return try { ${qualified(entry.name)}.valueOf(text) } catch (e: IllegalArgumentException) { error("$path: expected one of ${entry.cases.map((item) => item.name).join(', ')}") }`,
1735
+ '}',
1736
+ ])
1737
+ const source = [
1738
+ 'import androidx.annotation.Keep',
1739
+ ...(contract.views.length === 0 ? [] : ['import androidx.compose.runtime.Composable', 'import dev.onejs.one.OneNativeSourceViewDispatch']),
1740
+ 'import dev.onejs.one.OneNativeSourceDispatch',
1741
+ ...(needsEvents ? ['import dev.onejs.one.source.OneNativeSourceEvents'] : []),
1742
+ 'import org.json.JSONArray',
1743
+ 'import org.json.JSONObject',
1744
+ '',
1745
+ 'private fun readString(value: Any, path: String): String = value as? String ?: error("$path: expected string")',
1746
+ 'private fun readBoolean(value: Any, path: String): Boolean = value as? Boolean ?: error("$path: expected boolean")',
1747
+ 'private fun readDouble(value: Any, path: String): Double {',
1748
+ ' val result = (value as? Number)?.toDouble() ?: error("$path: expected number")',
1749
+ ' if (!result.isFinite()) error("$path: expected finite number")',
1750
+ ' return result',
1751
+ '}',
1752
+ 'private fun readInt(value: Any, path: String): Int {',
1753
+ ' val result = readDouble(value, path)',
1754
+ ' if (result % 1.0 != 0.0 || result < Int.MIN_VALUE || result > Int.MAX_VALUE) error("$path: expected Int")',
1755
+ ' return result.toInt()',
1756
+ '}',
1757
+ 'private fun readLong(value: Any, path: String): Long {',
1758
+ ' val result = readDouble(value, path)',
1759
+ ' if (result % 1.0 != 0.0 || result < -9007199254740991.0 || result > 9007199254740991.0) error("$path: expected safe Long")',
1760
+ ' return result.toLong()',
1761
+ '}',
1762
+ 'private fun <T> readList(value: Any, path: String, read: (Any, String) -> T): List<T> {',
1763
+ ' val array = value as? JSONArray ?: error("$path: expected array")',
1764
+ ' return (0 until array.length()).map { index -> read(array.get(index), "$path[$index]") }',
1765
+ '}',
1766
+ 'private fun <T> readMap(value: Any, path: String, read: (Any, String) -> T): Map<String, T> {',
1767
+ ' val objectValue = value as? JSONObject ?: error("$path: expected object")',
1768
+ ' return objectValue.keys().asSequence().associateWith { key -> read(objectValue.get(key), "$path.$key") }',
1769
+ '}',
1770
+ // the encoder writes ISO with milliseconds and reads with or without
1771
+ // them, so swift, kotlin and javascript dates all parse everywhere.
1772
+ ...(needsDate
1773
+ ? [
1774
+ 'private fun readDate(value: Any, path: String): java.util.Date {',
1775
+ ' val text = readString(value, path)',
1776
+ ' for (pattern in arrayOf("yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'", "yyyy-MM-dd\'T\'HH:mm:ss\'Z\'")) {',
1777
+ ' try {',
1778
+ ' return java.text.SimpleDateFormat(pattern, java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC"); isLenient = false }.parse(text) ?: error("$path: expected date")',
1779
+ ' } catch (parseError: java.text.ParseException) { }',
1780
+ ' }',
1781
+ ' error("$path: expected ISO date")',
1782
+ '}',
1783
+ 'private fun dateToString(value: java.util.Date): String =',
1784
+ ' java.text.SimpleDateFormat("yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }.format(value)',
1785
+ ]
1786
+ : []),
1787
+ ...(needsData
1788
+ ? [
1789
+ 'private fun readData(value: Any, path: String): ByteArray {',
1790
+ ' try {',
1791
+ ' return android.util.Base64.decode(readString(value, path), android.util.Base64.NO_WRAP)',
1792
+ ' } catch (decodeError: IllegalArgumentException) {',
1793
+ ' error("$path: expected base64 data")',
1794
+ ' }',
1795
+ '}',
1796
+ ]
1797
+ : []),
1798
+ ...structHelpers,
1799
+ ...enumHelpers,
1800
+ '',
1801
+ `@Keep class OneNativeSource_${sourceId}: OneNativeSourceDispatch {`,
1802
+ ` override val contractHash = ${JSON.stringify(hash)}`,
1803
+ ' override suspend fun call(module: String, method: String, argsJson: String): String {',
1804
+ ' val args = JSONArray(argsJson)',
1805
+ ' return when (module) {',
1806
+ ...contract.modules.flatMap((module) => [
1807
+ ` ${JSON.stringify(`${sourceId}.${module.name}`)} -> when (method) {`,
1808
+ ...module.methods.flatMap((method) => {
1809
+ const subscription = method.subscription
1810
+ if (subscription) {
1811
+ const channel = `OneNativeSource.${sourceId}.${module.name}.${method.name}`
1812
+ const closure = subscription.payload.length === 0
1813
+ ? `{ OneNativeSourceEvents.send(${JSON.stringify(channel)}, JSONArray().toString()) }`
1814
+ : `{ ${subscription.payload.map((_, index) => `a${index}`).join(', ')} -> OneNativeSourceEvents.send(${JSON.stringify(channel)}, JSONArray()${subscription.payload.map((arg, index) => `.put(${encode(arg.nativeType, `a${index}`)})`).join('')}.toString()) }`
1815
+ return [
1816
+ ` ${JSON.stringify(method.name)} -> {`,
1817
+ ` require(args.length() == 0) { "${sourceId}.${module.name}.${method.name} subscribes; it takes no arguments" }`,
1818
+ ` ${qualified(module.name)}.${method.name}(${closure})`,
1819
+ ' "null"',
1820
+ ' }',
1821
+ ]
1822
+ }
1823
+ const lines = [
1824
+ ` ${JSON.stringify(method.name)} -> {`,
1825
+ ` require(args.length() == ${method.parameters.length}) { "${sourceId}.${module.name}.${method.name} expects ${method.parameters.length} arguments" }`,
1826
+ ]
1827
+ method.parameters.forEach((parameter, index) => {
1828
+ lines.push(` val arg${index} = ${value(parameter.nativeType, `args.get(${index})`, JSON.stringify(`args[${index}]`))}`)
1829
+ })
1830
+ const call = `${qualified(module.name)}.${method.name}(${method.parameters.map((_, index) => `arg${index}`).join(', ')})`
1831
+ if (method.result === 'void') {
1832
+ lines.push(` ${call}`, ' "null"')
1833
+ } else {
1834
+ lines.push(` val result = ${call}`)
1835
+ if (method.nativeResult === 'Long') {
1836
+ lines.push(' require(result >= -9007199254740991L && result <= 9007199254740991L) { "result exceeds the JavaScript safe integer range" }')
1837
+ }
1838
+ lines.push(` JSONArray().put(${encode(method.nativeResult, 'result')}).toString().drop(1).dropLast(1)`)
1839
+ }
1840
+ return [...lines, ' }']
1841
+ }),
1842
+ ' else -> error("native method $module.$method is not linked; rebuild the app")',
1843
+ ' }',
1844
+ ]),
1845
+ ' else -> error("native module $module is not linked; rebuild the app")',
1846
+ ' }',
1847
+ ' }',
1848
+ '}',
1849
+ '',
1850
+ ...(contract.views.length === 0 ? [] : viewGlue(sourceId, contract, hash, value, encode)),
1851
+ ].join('\n')
1852
+ return { hash, source }
1853
+ }
1854
+
1855
+ // renders an imported composable from the host's json props; a callback
1856
+ // parameter reports its arguments to the host as a json array.
1857
+ function viewGlue(
1858
+ sourceId: string,
1859
+ contract: NativeSourceContract,
1860
+ hash: string,
1861
+ value: (type: string, input: string, path: string) => string,
1862
+ encode: (type: string, accessor: string) => string
1863
+ ): string[] {
1864
+ const qualifier = contract.packageName ? `${contract.packageName}.` : ''
1865
+ return [
1866
+ `@Keep class OneNativeSourceViews_${sourceId}: OneNativeSourceViewDispatch {`,
1867
+ ` override val contractHash = ${JSON.stringify(hash)}`,
1868
+ ' @Composable',
1869
+ ' override fun Content(view: String, propsJson: String, emit: (String, String) -> Unit) {',
1870
+ ' val props = JSONObject(propsJson)',
1871
+ ' when (view) {',
1872
+ ...contract.views.flatMap((view) => [
1873
+ ` ${JSON.stringify(view.name)} -> ${qualifier}${view.name}(`,
1874
+ ...view.props.map((prop) => {
1875
+ const path = JSON.stringify(`props.${prop.name}`)
1876
+ if (prop.callback) {
1877
+ const args = prop.callback.map((_, index) => `a${index}`)
1878
+ const types = prop.callback.map((arg) => arg.nativeType)
1879
+ const body = `emit(${JSON.stringify(prop.name)}, JSONArray()${args.map((arg, index) => `.put(${encode(types[index], arg)})`).join('')}.toString())`
1880
+ const lambda = `{ ${args.length > 0 ? `${args.join(', ')} -> ` : ''}${body} }`
1881
+ // the host sends true for each callback the element passed
1882
+ return ` ${prop.name} = ${prop.nativeType.endsWith('?') ? `if (props.optBoolean(${JSON.stringify(prop.name)})) (${lambda}) else null` : lambda},`
1883
+ }
1884
+ const input = prop.optional
1885
+ ? `(props.opt(${JSON.stringify(prop.name)}) ?: JSONObject.NULL)`
1886
+ : `(props.opt(${JSON.stringify(prop.name)}) ?: error(${JSON.stringify(`props.${prop.name}: required`)}))`
1887
+ return ` ${prop.name} = ${value(prop.nativeType, input, path)},`
1888
+ }),
1889
+ ' )',
1890
+ ]),
1891
+ ' else -> error("composable $view is not linked; rebuild the app")',
1892
+ ' }',
1893
+ ' }',
1894
+ '}',
1895
+ '',
1896
+ ]
1897
+ }