conjure-js 0.0.11 → 0.0.13

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 (80) hide show
  1. package/dist-cli/conjure-js.mjs +9336 -5028
  2. package/dist-vite-plugin/index.mjs +10455 -0
  3. package/package.json +9 -2
  4. package/src/bin/cli.ts +2 -2
  5. package/src/bin/nrepl-symbol.ts +150 -0
  6. package/src/bin/nrepl.ts +301 -157
  7. package/src/bin/version.ts +1 -1
  8. package/src/clojure/core.clj +764 -29
  9. package/src/clojure/core.clj.d.ts +76 -4
  10. package/src/clojure/demo/math.clj +5 -1
  11. package/src/clojure/generated/builtin-namespace-registry.ts +4 -0
  12. package/src/clojure/generated/clojure-core-source.ts +765 -29
  13. package/src/clojure/generated/clojure-set-source.ts +136 -0
  14. package/src/clojure/generated/clojure-walk-source.ts +72 -0
  15. package/src/clojure/set.clj +132 -0
  16. package/src/clojure/set.clj.d.ts +20 -0
  17. package/src/clojure/string.clj.d.ts +14 -0
  18. package/src/clojure/walk.clj +68 -0
  19. package/src/clojure/walk.clj.d.ts +7 -0
  20. package/src/core/assertions.ts +114 -6
  21. package/src/core/bootstrap.ts +337 -0
  22. package/src/core/conversions.ts +48 -31
  23. package/src/core/core-module.ts +303 -0
  24. package/src/core/env.ts +42 -7
  25. package/src/core/errors.ts +8 -0
  26. package/src/core/evaluator/apply.ts +40 -25
  27. package/src/core/evaluator/arity.ts +8 -8
  28. package/src/core/evaluator/async-evaluator.ts +565 -0
  29. package/src/core/evaluator/collections.ts +30 -4
  30. package/src/core/evaluator/destructure.ts +180 -69
  31. package/src/core/evaluator/dispatch.ts +24 -14
  32. package/src/core/evaluator/evaluate.ts +22 -20
  33. package/src/core/evaluator/expand.ts +45 -15
  34. package/src/core/evaluator/form-parsers.ts +178 -0
  35. package/src/core/evaluator/index.ts +7 -9
  36. package/src/core/evaluator/js-interop.ts +189 -0
  37. package/src/core/evaluator/quasiquote.ts +14 -8
  38. package/src/core/evaluator/recur-check.ts +6 -6
  39. package/src/core/evaluator/special-forms.ts +380 -173
  40. package/src/core/factories.ts +182 -3
  41. package/src/core/index.ts +55 -5
  42. package/src/core/module.ts +136 -0
  43. package/src/core/ns-forms.ts +107 -0
  44. package/src/core/positions.ts +9 -2
  45. package/src/core/printer.ts +371 -11
  46. package/src/core/reader.ts +127 -29
  47. package/src/core/registry.ts +209 -0
  48. package/src/core/runtime.ts +376 -0
  49. package/src/core/session.ts +263 -478
  50. package/src/core/stdlib/arithmetic.ts +516 -215
  51. package/src/core/stdlib/async-fns.ts +132 -0
  52. package/src/core/stdlib/atoms.ts +286 -63
  53. package/src/core/stdlib/errors.ts +54 -50
  54. package/src/core/stdlib/hof.ts +74 -173
  55. package/src/core/stdlib/js-namespace.ts +344 -0
  56. package/src/core/stdlib/lazy.ts +34 -0
  57. package/src/core/stdlib/maps-sets.ts +322 -0
  58. package/src/core/stdlib/meta.ts +109 -28
  59. package/src/core/stdlib/predicates.ts +322 -196
  60. package/src/core/stdlib/regex.ts +126 -98
  61. package/src/core/stdlib/seq.ts +564 -0
  62. package/src/core/stdlib/strings.ts +164 -135
  63. package/src/core/stdlib/transducers.ts +95 -100
  64. package/src/core/stdlib/utils.ts +283 -147
  65. package/src/core/stdlib/vars.ts +27 -27
  66. package/src/core/stdlib/vectors.ts +122 -0
  67. package/src/core/tokenizer.ts +13 -3
  68. package/src/core/transformations.ts +117 -9
  69. package/src/core/types.ts +118 -6
  70. package/src/host/node-host-module.ts +74 -0
  71. package/src/nrepl/relay.ts +432 -0
  72. package/src/vite-plugin-clj/codegen.ts +87 -95
  73. package/src/vite-plugin-clj/index.ts +242 -18
  74. package/src/vite-plugin-clj/namespace-utils.ts +39 -0
  75. package/src/vite-plugin-clj/static-analysis.ts +211 -0
  76. package/src/clojure/demo.clj +0 -63
  77. package/src/clojure/demo.clj.d.ts +0 -0
  78. package/src/core/core-env.ts +0 -60
  79. package/src/core/stdlib/collections.ts +0 -784
  80. package/src/host/node.ts +0 -55
@@ -0,0 +1,432 @@
1
+ import * as net from 'node:net'
2
+ import { writeFileSync, unlinkSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { BDecoderStream, BEncoderStream } from '../bin/bencode'
5
+ import type { Session } from '../core'
6
+ import type { WebSocketServer } from 'vite'
7
+ import { VERSION } from '../bin/version'
8
+ import { resolveSymbol, extractMeta } from '../bin/nrepl-symbol'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ type NreplMessage = Record<string, unknown>
15
+
16
+ /** Lightweight per-Calva-session state — no Clojure session, just bookkeeping */
17
+ type RelaySession = {
18
+ id: string
19
+ currentNs: string
20
+ }
21
+
22
+ type BrowserResult = {
23
+ id: string
24
+ value?: string
25
+ error?: string
26
+ ns?: string
27
+ out?: string
28
+ }
29
+
30
+ type PendingResolver = (result: BrowserResult) => void
31
+
32
+ export type BrowserNreplRelayOptions = {
33
+ port?: number
34
+ host?: string
35
+ cwd: string
36
+ ws: WebSocketServer
37
+ serverSession: Session
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Protocol helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function makeId(): string {
45
+ return crypto.randomUUID()
46
+ }
47
+
48
+ function send(encoder: BEncoderStream, msg: NreplMessage) {
49
+ encoder.write(msg)
50
+ }
51
+
52
+ function done(
53
+ encoder: BEncoderStream,
54
+ id: string,
55
+ sessionId: string | undefined,
56
+ extra: NreplMessage = {}
57
+ ) {
58
+ send(encoder, {
59
+ id,
60
+ ...(sessionId ? { session: sessionId } : {}),
61
+ status: ['done'],
62
+ ...extra,
63
+ })
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Browser forwarding
68
+ // ---------------------------------------------------------------------------
69
+
70
+ async function forwardToB(
71
+ event: string,
72
+ data: Record<string, unknown>,
73
+ ws: WebSocketServer,
74
+ pending: Map<string, PendingResolver>,
75
+ timeoutMs = 15000
76
+ ): Promise<BrowserResult> {
77
+ const correlationId = makeId()
78
+
79
+ if (ws.clients.size === 0) {
80
+ return { id: correlationId, error: 'No browser tab connected to Vite dev server' }
81
+ }
82
+
83
+ return new Promise<BrowserResult>((resolve) => {
84
+ const timer = setTimeout(() => {
85
+ if (pending.has(correlationId)) {
86
+ pending.delete(correlationId)
87
+ resolve({ id: correlationId, error: 'Timed out — no response from browser (15s)' })
88
+ }
89
+ }, timeoutMs)
90
+
91
+ pending.set(correlationId, (result) => {
92
+ clearTimeout(timer)
93
+ resolve(result)
94
+ })
95
+
96
+ ws.send({ type: 'custom', event, data: { ...data, id: correlationId } })
97
+ })
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Op handlers
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function handleClone(
105
+ msg: NreplMessage,
106
+ sessions: Map<string, RelaySession>,
107
+ encoder: BEncoderStream
108
+ ) {
109
+ const id = (msg['id'] as string) ?? ''
110
+ const newId = makeId()
111
+ sessions.set(newId, { id: newId, currentNs: 'user' })
112
+ done(encoder, id, undefined, { 'new-session': newId })
113
+ }
114
+
115
+ function handleDescribe(msg: NreplMessage, encoder: BEncoderStream) {
116
+ const id = (msg['id'] as string) ?? ''
117
+ const sessionId = msg['session'] as string | undefined
118
+ done(encoder, id, sessionId, {
119
+ ops: {
120
+ eval: {},
121
+ clone: {},
122
+ close: {},
123
+ complete: {},
124
+ describe: {},
125
+ eldoc: {},
126
+ info: {},
127
+ lookup: {},
128
+ 'load-file': {},
129
+ },
130
+ versions: { conjure: { 'version-string': VERSION } },
131
+ })
132
+ }
133
+
134
+ function handleComplete(
135
+ msg: NreplMessage,
136
+ session: RelaySession,
137
+ encoder: BEncoderStream,
138
+ serverSession: Session
139
+ ) {
140
+ const id = (msg['id'] as string) ?? ''
141
+ const prefix = (msg['prefix'] as string) ?? ''
142
+ const nsName = (msg['ns'] as string) ?? session.currentNs
143
+ const names = serverSession.getCompletions(prefix, nsName)
144
+ const completions = names.map((c) => ({
145
+ candidate: c,
146
+ type: 'var',
147
+ ns: session.currentNs,
148
+ }))
149
+ done(encoder, id, session.id, { completions })
150
+ }
151
+
152
+ function handleClose(
153
+ msg: NreplMessage,
154
+ sessions: Map<string, RelaySession>,
155
+ encoder: BEncoderStream
156
+ ) {
157
+ const id = (msg['id'] as string) ?? ''
158
+ const sessionId = (msg['session'] as string) ?? ''
159
+ sessions.delete(sessionId)
160
+ send(encoder, { id, session: sessionId, status: ['done'] })
161
+ }
162
+
163
+ function handleInfo(
164
+ msg: NreplMessage,
165
+ session: RelaySession,
166
+ encoder: BEncoderStream,
167
+ serverSession: Session
168
+ ) {
169
+ const id = (msg['id'] as string) ?? ''
170
+ const sym = msg['sym'] as string | undefined
171
+ const nsOverride = msg['ns'] as string | undefined
172
+
173
+ if (!sym) {
174
+ done(encoder, id, session.id, { status: ['no-info', 'done'] })
175
+ return
176
+ }
177
+
178
+ const resolved = resolveSymbol(sym, serverSession, nsOverride ?? session.currentNs)
179
+ if (!resolved) {
180
+ done(encoder, id, session.id, { status: ['no-info', 'done'] })
181
+ return
182
+ }
183
+
184
+ const meta = extractMeta(resolved.value, resolved.varObj?.meta)
185
+ done(encoder, id, session.id, {
186
+ ns: resolved.resolvedNs,
187
+ name: resolved.localName,
188
+ doc: meta.doc,
189
+ 'arglists-str': meta.arglistsStr,
190
+ type: meta.type,
191
+ })
192
+ }
193
+
194
+ function handleEldoc(
195
+ msg: NreplMessage,
196
+ session: RelaySession,
197
+ encoder: BEncoderStream,
198
+ serverSession: Session
199
+ ) {
200
+ const id = (msg['id'] as string) ?? ''
201
+ const sym = msg['sym'] as string | undefined
202
+ const nsOverride = msg['ns'] as string | undefined
203
+
204
+ if (!sym) {
205
+ done(encoder, id, session.id, { status: ['no-eldoc', 'done'] })
206
+ return
207
+ }
208
+
209
+ const resolved = resolveSymbol(sym, serverSession, nsOverride ?? session.currentNs)
210
+ if (!resolved) {
211
+ done(encoder, id, session.id, { status: ['no-eldoc', 'done'] })
212
+ return
213
+ }
214
+
215
+ const meta = extractMeta(resolved.value, resolved.varObj?.meta)
216
+ if (!meta.eldocArgs) {
217
+ done(encoder, id, session.id, { status: ['no-eldoc', 'done'] })
218
+ return
219
+ }
220
+
221
+ done(encoder, id, session.id, {
222
+ name: resolved.localName,
223
+ ns: resolved.resolvedNs,
224
+ type: meta.type,
225
+ eldoc: meta.eldocArgs,
226
+ })
227
+ }
228
+
229
+ function handleUnknown(msg: NreplMessage, encoder: BEncoderStream) {
230
+ const id = (msg['id'] as string) ?? ''
231
+ send(encoder, { id, status: ['unknown-op', 'done'] })
232
+ }
233
+
234
+ async function handleEval(
235
+ msg: NreplMessage,
236
+ session: RelaySession,
237
+ encoder: BEncoderStream,
238
+ ws: WebSocketServer,
239
+ pending: Map<string, PendingResolver>
240
+ ) {
241
+ const id = (msg['id'] as string) ?? ''
242
+ const code = (msg['code'] as string) ?? ''
243
+
244
+ const result = await forwardToB(
245
+ 'conjure:eval',
246
+ { code, ns: session.currentNs },
247
+ ws,
248
+ pending
249
+ )
250
+
251
+ if (result.ns) session.currentNs = result.ns
252
+ if (result.out) send(encoder, { id, session: session.id, out: result.out })
253
+
254
+ if (result.error) {
255
+ done(encoder, id, session.id, {
256
+ ex: result.error,
257
+ err: result.error + '\n',
258
+ ns: session.currentNs,
259
+ status: ['eval-error', 'done'],
260
+ })
261
+ } else {
262
+ done(encoder, id, session.id, { value: result.value ?? 'nil', ns: session.currentNs })
263
+ }
264
+ }
265
+
266
+ async function handleLoadFile(
267
+ msg: NreplMessage,
268
+ session: RelaySession,
269
+ encoder: BEncoderStream,
270
+ ws: WebSocketServer,
271
+ pending: Map<string, PendingResolver>
272
+ ) {
273
+ const id = (msg['id'] as string) ?? ''
274
+ const source = (msg['file'] as string) ?? ''
275
+ const fileName = (msg['file-name'] as string) ?? ''
276
+ const filePath = (msg['file-path'] as string) ?? ''
277
+ const nsHint = fileName.replace(/\.clj$/, '').replace(/\//g, '.') || undefined
278
+
279
+ const result = await forwardToB(
280
+ 'conjure:load-file',
281
+ { source, nsHint, filePath },
282
+ ws,
283
+ pending
284
+ )
285
+
286
+ if (result.ns) session.currentNs = result.ns
287
+ if (result.out) send(encoder, { id, session: session.id, out: result.out })
288
+
289
+ if (result.error) {
290
+ done(encoder, id, session.id, {
291
+ ex: result.error,
292
+ err: result.error + '\n',
293
+ ns: session.currentNs,
294
+ status: ['eval-error', 'done'],
295
+ })
296
+ } else {
297
+ done(encoder, id, session.id, { value: result.value ?? 'nil', ns: session.currentNs })
298
+ }
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Per-connection dispatcher
303
+ // ---------------------------------------------------------------------------
304
+
305
+ async function handleMessage(
306
+ msg: NreplMessage,
307
+ sessions: Map<string, RelaySession>,
308
+ defaultSession: RelaySession,
309
+ encoder: BEncoderStream,
310
+ ws: WebSocketServer,
311
+ pending: Map<string, PendingResolver>,
312
+ serverSession: Session
313
+ ) {
314
+ const op = msg['op'] as string
315
+ const sessionId = msg['session'] as string | undefined
316
+ const session = sessionId ? (sessions.get(sessionId) ?? defaultSession) : defaultSession
317
+
318
+ switch (op) {
319
+ case 'clone':
320
+ handleClone(msg, sessions, encoder)
321
+ break
322
+ case 'describe':
323
+ handleDescribe(msg, encoder)
324
+ break
325
+ case 'eval':
326
+ await handleEval(msg, session, encoder, ws, pending)
327
+ break
328
+ case 'load-file':
329
+ await handleLoadFile(msg, session, encoder, ws, pending)
330
+ break
331
+ case 'complete':
332
+ handleComplete(msg, session, encoder, serverSession)
333
+ break
334
+ case 'close':
335
+ handleClose(msg, sessions, encoder)
336
+ break
337
+ case 'info':
338
+ case 'lookup':
339
+ handleInfo(msg, session, encoder, serverSession)
340
+ break
341
+ case 'eldoc':
342
+ handleEldoc(msg, session, encoder, serverSession)
343
+ break
344
+ default:
345
+ handleUnknown(msg, encoder)
346
+ }
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Startup
351
+ // ---------------------------------------------------------------------------
352
+
353
+ export function startBrowserNreplRelay(options: BrowserNreplRelayOptions): net.Server {
354
+ const port = options.port ?? 7888
355
+ const host = options.host ?? '127.0.0.1'
356
+ const { ws, serverSession, cwd } = options
357
+
358
+ // Shared pending map — keyed by correlation ID, resolved when browser responds
359
+ const pending = new Map<string, PendingResolver>()
360
+
361
+ // Wire up browser result listeners once at server level
362
+ ws.on('conjure:eval-result', (data: BrowserResult) => {
363
+ const resolve = pending.get(data.id)
364
+ if (resolve) {
365
+ pending.delete(data.id)
366
+ resolve(data)
367
+ }
368
+ })
369
+
370
+ ws.on('conjure:load-file-result', (data: BrowserResult) => {
371
+ const resolve = pending.get(data.id)
372
+ if (resolve) {
373
+ pending.delete(data.id)
374
+ resolve(data)
375
+ }
376
+ })
377
+
378
+ const server = net.createServer((socket) => {
379
+ const encoder = new BEncoderStream()
380
+ const decoder = new BDecoderStream()
381
+
382
+ encoder.pipe(socket)
383
+ socket.pipe(decoder)
384
+
385
+ const sessions = new Map<string, RelaySession>()
386
+ const defaultId = makeId()
387
+ const defaultSession: RelaySession = { id: defaultId, currentNs: 'user' }
388
+ sessions.set(defaultId, defaultSession)
389
+
390
+ decoder.on('data', (msg: NreplMessage) => {
391
+ handleMessage(msg, sessions, defaultSession, encoder, ws, pending, serverSession).catch(
392
+ (err) => {
393
+ console.error('[conjure] relay error:', err)
394
+ }
395
+ )
396
+ })
397
+
398
+ socket.on('error', () => {
399
+ // connection dropped
400
+ })
401
+
402
+ socket.on('close', () => {
403
+ sessions.clear()
404
+ })
405
+ })
406
+
407
+ const portFile = join(cwd, '.nrepl-port')
408
+
409
+ server.on('error', (err: NodeJS.ErrnoException) => {
410
+ if (err.code === 'EADDRINUSE') {
411
+ console.warn(
412
+ `[conjure] Port ${port} already in use — browser nREPL relay not started. ` +
413
+ `Kill the process holding the port or set a different nreplPort.`
414
+ )
415
+ } else {
416
+ console.error('[conjure] nREPL relay error:', err.message)
417
+ }
418
+ })
419
+
420
+ server.listen(port, host, () => {
421
+ writeFileSync(portFile, String(port), 'utf8')
422
+ console.log(`[conjure] Browser nREPL relay started on port ${port}`)
423
+ })
424
+
425
+ const cleanup = () => {
426
+ if (existsSync(portFile)) unlinkSync(portFile)
427
+ }
428
+
429
+ server.on('close', cleanup)
430
+
431
+ return server
432
+ }
@@ -1,10 +1,8 @@
1
- import { isMacro } from '../core/assertions'
2
- import type { Session } from '../core/session'
3
- import type { Arity, CljValue } from '../core/types'
4
- import { extractNsName, extractNsRequires } from './namespace-utils'
1
+ import type { Arity, DestructurePattern } from '../core/types'
2
+ import { extractNsName, extractNsRequires, extractStringRequires } from './namespace-utils'
3
+ import { readNamespaceVars } from './static-analysis'
5
4
 
6
5
  export interface CodegenContext {
7
- session: Session
8
6
  sourceRoots: string[]
9
7
  coreIndexPath: string
10
8
  virtualSessionId: string
@@ -14,11 +12,13 @@ export interface CodegenContext {
14
12
  export function generateModuleCode(
15
13
  ctx: CodegenContext,
16
14
  nsNameFromPath: string,
17
- source: string
15
+ source: string,
16
+ filePath?: string
18
17
  ): string {
19
18
  const nsName = extractNsName(source) ?? nsNameFromPath
20
19
 
21
- ctx.session.loadFile(source, nsName)
20
+ // Detect string requires from AST — determines sync vs async load call.
21
+ const hasStringRequires = extractStringRequires(source, filePath).length > 0
22
22
 
23
23
  const requires = extractNsRequires(source)
24
24
  const depImports = requires
@@ -30,91 +30,120 @@ export function generateModuleCode(
30
30
  .filter(Boolean)
31
31
  .join('\n')
32
32
 
33
- const nsData = ctx.session.getNs(nsName)
34
- if (!nsData) {
35
- return `throw new Error('Namespace ${nsName} failed to load');`
36
- }
37
-
33
+ // Static analysis: pure AST walk, no execution.
34
+ const vars = readNamespaceVars(source)
38
35
  const exportLines: string[] = []
39
- for (const [name, v] of nsData.vars) {
40
- const value = v.value
41
- if (isMacro(value)) continue
42
36
 
43
- const safeName = safeJsIdentifier(name)
37
+ for (const descriptor of vars) {
38
+ if (descriptor.isMacro) continue
39
+ if (descriptor.isPrivate) continue
40
+
41
+ const safeName = safeJsIdentifier(descriptor.name)
44
42
  // At runtime, vars.get() returns a CljVar; deref with .value
45
- const deref = `__ns.vars.get(${JSON.stringify(name)}).value`
46
- if (isAFunction(value)) {
43
+ const deref = `__ns.vars.get(${JSON.stringify(descriptor.name)}).value`
44
+
45
+ if (descriptor.kind === 'fn') {
47
46
  exportLines.push(
48
47
  `export function ${safeName}(...args) {` +
49
48
  ` const fn = ${deref};` +
50
49
  ` const cljArgs = args.map(jsToClj);` +
51
- ` const result = applyFunction(fn, cljArgs);` +
52
- ` return cljToJs(result);` +
50
+ ` const result = __session.applyFunction(fn, cljArgs);` +
51
+ ` return cljToJs(result, __session);` +
53
52
  `}`
54
53
  )
55
54
  } else {
56
55
  exportLines.push(
57
- `export const ${safeName} = cljToJs(${deref});`
56
+ `export const ${safeName} = cljToJs(${deref}, __session);`
58
57
  )
59
58
  }
60
59
  }
61
60
 
62
61
  const escapedSource = JSON.stringify(source)
62
+ // Files with string requires need async loading (top-level await, requires target: esnext).
63
+ // Files without string requires use the sync path — no top-level await overhead.
64
+ const loadCall = hasStringRequires
65
+ ? `await __session.loadFileAsync(${escapedSource}, ${JSON.stringify(nsName)});`
66
+ : `__session.loadFile(${escapedSource}, ${JSON.stringify(nsName)});`
67
+
68
+ if (exportLines.length === 0) {
69
+ // No public exports — emit a minimal module that loads the namespace at runtime.
70
+ // Namespace will be available in the session even without JS-side exports.
71
+ return [
72
+ `import { getSession } from ${JSON.stringify(ctx.virtualSessionId)};`,
73
+ depImports,
74
+ ``,
75
+ `const __session = getSession();`,
76
+ loadCall,
77
+ ``,
78
+ `if (import.meta.hot) { import.meta.hot.accept() }`,
79
+ ].join('\n')
80
+ }
63
81
 
64
82
  return [
65
83
  `import { getSession } from ${JSON.stringify(ctx.virtualSessionId)};`,
66
- `import { cljToJs, jsToClj, applyFunction } from ${JSON.stringify(ctx.coreIndexPath)};`,
84
+ `import { cljToJs, jsToClj } from ${JSON.stringify(ctx.coreIndexPath)};`,
67
85
  depImports,
68
86
  ``,
69
87
  `const __session = getSession();`,
70
- `__session.loadFile(${escapedSource}, ${JSON.stringify(nsName)});`,
88
+ loadCall,
71
89
  `const __ns = __session.getNs(${JSON.stringify(nsName)});`,
72
90
  ``,
73
91
  ...exportLines,
92
+ ``,
93
+ `// Self-accept HMR: re-execute this module on save (updates browser session)`,
94
+ `// without propagating to parent modules — prevents full page reload.`,
95
+ `if (import.meta.hot) { import.meta.hot.accept() }`,
74
96
  ].join('\n')
75
97
  }
76
98
 
77
- function isAFunction(value: CljValue): boolean {
78
- return value.kind === 'function' || value.kind === 'native-function'
79
- }
99
+ export function generateDts(
100
+ _ctx: CodegenContext,
101
+ nsNameFromPath: string,
102
+ source: string
103
+ ): string {
104
+ const nsName = extractNsName(source) ?? nsNameFromPath
105
+ const vars = readNamespaceVars(source)
80
106
 
81
- function cljValueToTsType(value: CljValue): string {
82
- switch (value.kind) {
83
- case 'number':
84
- return 'number'
85
- case 'string':
86
- return 'string'
87
- case 'boolean':
88
- return 'boolean'
89
- case 'nil':
90
- return 'null'
91
- case 'keyword':
92
- return 'string'
93
- case 'symbol':
94
- return 'string'
95
- case 'list':
96
- case 'vector':
97
- return 'unknown[]'
98
- case 'map':
99
- return 'Record<string, unknown>'
100
- case 'function':
101
- case 'native-function':
102
- return '(...args: unknown[]) => unknown'
103
- case 'macro':
104
- return 'never'
105
- case 'var':
106
- return 'unknown'
107
- default:
108
- throw new Error(`Unknown CljValue kind: ${value.kind}`)
107
+ const declarations: string[] = []
108
+ for (const descriptor of vars) {
109
+ if (descriptor.isMacro) continue
110
+ if (descriptor.isPrivate) continue
111
+
112
+ const safeName = safeJsIdentifier(descriptor.name)
113
+
114
+ if (descriptor.kind === 'fn') {
115
+ if (descriptor.arities && descriptor.arities.length > 0) {
116
+ for (const arity of descriptor.arities) {
117
+ declarations.push(`export function ${safeName}${arityToSignature(arity)};`)
118
+ }
119
+ } else {
120
+ declarations.push(`export function ${safeName}(...args: unknown[]): unknown;`)
121
+ }
122
+ } else {
123
+ // 'const' with inferred type, or 'unknown'
124
+ const tsType = descriptor.tsType ?? 'unknown'
125
+ declarations.push(`export const ${safeName}: ${tsType};`)
126
+ }
109
127
  }
128
+
129
+ // Suppress the unused-variable warning — nsName is used for documentation only here
130
+ void nsName
131
+
132
+ return declarations.join('\n')
110
133
  }
111
134
 
135
+ // ---------------------------------------------------------------------------
136
+ // Signature helpers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ type ArityShape = { params: DestructurePattern[]; restParam: DestructurePattern | null }
140
+
112
141
  function patternName(p: Arity['params'][number], index: number): string {
113
142
  if (p.kind === 'symbol') return safeJsIdentifier(p.name)
114
143
  return `arg${index}`
115
144
  }
116
145
 
117
- function arityToSignature(arity: Arity): string {
146
+ function arityToSignature(arity: ArityShape): string {
118
147
  const fixedParams = arity.params
119
148
  .map((p, i) => `${patternName(p, i)}: unknown`)
120
149
  .join(', ')
@@ -133,46 +162,9 @@ function arityToSignature(arity: Arity): string {
133
162
  return `(${fixedParams}): unknown`
134
163
  }
135
164
 
136
- export function generateDts(
137
- ctx: CodegenContext,
138
- nsNameFromPath: string,
139
- source: string
140
- ): string {
141
- const nsName = extractNsName(source) ?? nsNameFromPath
142
-
143
- try {
144
- ctx.session.loadFile(source, nsName)
145
- } catch {
146
- return ''
147
- }
148
-
149
- const nsData = ctx.session.getNs(nsName)
150
- if (!nsData) return ''
151
-
152
- const declarations: string[] = []
153
- for (const [name, v] of nsData.vars) {
154
- const value = v.value
155
- if (isMacro(value)) continue
156
-
157
- const safeName = safeJsIdentifier(name)
158
-
159
- if (value.kind === 'function') {
160
- for (const arity of value.arities) {
161
- declarations.push(
162
- `export function ${safeName}${arityToSignature(arity)};`
163
- )
164
- }
165
- } else if (value.kind === 'native-function') {
166
- declarations.push(
167
- `export function ${safeName}(...args: unknown[]): unknown;`
168
- )
169
- } else {
170
- declarations.push(`export const ${safeName}: ${cljValueToTsType(value)};`)
171
- }
172
- }
173
-
174
- return declarations.join('\n')
175
- }
165
+ // ---------------------------------------------------------------------------
166
+ // Identifier sanitization
167
+ // ---------------------------------------------------------------------------
176
168
 
177
169
  const JS_RESERVED_WORDS = new Set([
178
170
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',