conjure-js 0.0.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 (80) hide show
  1. package/conjure +0 -0
  2. package/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  3. package/dist/assets/editor.worker-CdQrwHl8.js +26 -0
  4. package/dist/assets/main-A7ZMId9A.css +1 -0
  5. package/dist/assets/main-CmI-7epE.js +3137 -0
  6. package/dist/index.html +195 -0
  7. package/dist/vite.svg +1 -0
  8. package/package.json +68 -0
  9. package/src/bin/__fixtures__/smoke/app/lib.clj +4 -0
  10. package/src/bin/__fixtures__/smoke/app/main.clj +4 -0
  11. package/src/bin/__fixtures__/smoke/repl-smoke.ts +12 -0
  12. package/src/bin/bencode.ts +205 -0
  13. package/src/bin/cli.ts +250 -0
  14. package/src/bin/nrepl-utils.ts +59 -0
  15. package/src/bin/nrepl.ts +393 -0
  16. package/src/bin/version.ts +4 -0
  17. package/src/clojure/core.clj +620 -0
  18. package/src/clojure/core.clj.d.ts +189 -0
  19. package/src/clojure/demo/math.clj +16 -0
  20. package/src/clojure/demo/math.clj.d.ts +4 -0
  21. package/src/clojure/demo.clj +42 -0
  22. package/src/clojure/demo.clj.d.ts +0 -0
  23. package/src/clojure/generated/builtin-namespace-registry.ts +14 -0
  24. package/src/clojure/generated/clojure-core-source.ts +623 -0
  25. package/src/clojure/generated/clojure-string-source.ts +196 -0
  26. package/src/clojure/string.clj +192 -0
  27. package/src/clojure/string.clj.d.ts +25 -0
  28. package/src/core/assertions.ts +134 -0
  29. package/src/core/conversions.ts +108 -0
  30. package/src/core/core-env.ts +58 -0
  31. package/src/core/env.ts +78 -0
  32. package/src/core/errors.ts +39 -0
  33. package/src/core/evaluator/apply.ts +114 -0
  34. package/src/core/evaluator/arity.ts +174 -0
  35. package/src/core/evaluator/collections.ts +25 -0
  36. package/src/core/evaluator/destructure.ts +247 -0
  37. package/src/core/evaluator/dispatch.ts +73 -0
  38. package/src/core/evaluator/evaluate.ts +100 -0
  39. package/src/core/evaluator/expand.ts +79 -0
  40. package/src/core/evaluator/index.ts +72 -0
  41. package/src/core/evaluator/quasiquote.ts +87 -0
  42. package/src/core/evaluator/recur-check.ts +109 -0
  43. package/src/core/evaluator/special-forms.ts +517 -0
  44. package/src/core/factories.ts +155 -0
  45. package/src/core/gensym.ts +9 -0
  46. package/src/core/index.ts +76 -0
  47. package/src/core/positions.ts +38 -0
  48. package/src/core/printer.ts +86 -0
  49. package/src/core/reader.ts +559 -0
  50. package/src/core/scanners.ts +93 -0
  51. package/src/core/session.ts +610 -0
  52. package/src/core/stdlib/arithmetic.ts +361 -0
  53. package/src/core/stdlib/atoms.ts +88 -0
  54. package/src/core/stdlib/collections.ts +784 -0
  55. package/src/core/stdlib/errors.ts +81 -0
  56. package/src/core/stdlib/hof.ts +307 -0
  57. package/src/core/stdlib/meta.ts +48 -0
  58. package/src/core/stdlib/predicates.ts +240 -0
  59. package/src/core/stdlib/regex.ts +238 -0
  60. package/src/core/stdlib/strings.ts +311 -0
  61. package/src/core/stdlib/transducers.ts +256 -0
  62. package/src/core/stdlib/utils.ts +287 -0
  63. package/src/core/tokenizer.ts +437 -0
  64. package/src/core/transformations.ts +75 -0
  65. package/src/core/types.ts +258 -0
  66. package/src/main.ts +1 -0
  67. package/src/monaco-esm.d.ts +7 -0
  68. package/src/playground/clojure-tokens.ts +67 -0
  69. package/src/playground/editor.worker.ts +5 -0
  70. package/src/playground/find-form.ts +138 -0
  71. package/src/playground/playground.ts +342 -0
  72. package/src/playground/samples/00-welcome.clj +385 -0
  73. package/src/playground/samples/01-collections.clj +191 -0
  74. package/src/playground/samples/02-higher-order-functions.clj +215 -0
  75. package/src/playground/samples/03-destructuring.clj +194 -0
  76. package/src/playground/samples/04-strings-and-regex.clj +202 -0
  77. package/src/playground/samples/05-error-handling.clj +212 -0
  78. package/src/repl/repl.ts +116 -0
  79. package/tsconfig.build.json +10 -0
  80. package/tsconfig.json +31 -0
package/src/bin/cli.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { createInterface } from 'node:readline/promises'
4
+ import { stdin as input, stdout as output } from 'node:process'
5
+ import { fileURLToPath } from 'node:url'
6
+ import {
7
+ createSession,
8
+ printString,
9
+ define,
10
+ cljNativeFunction,
11
+ cljNil,
12
+ cljString,
13
+ valueToString,
14
+ type Session,
15
+ type CljValue,
16
+ } from '../core'
17
+ import { extractNsName } from '../vite-plugin-clj/namespace-utils'
18
+ import { inferSourceRoot, discoverSourceRoots } from './nrepl-utils'
19
+ import { startNreplServer } from './nrepl'
20
+ import { VERSION } from './version'
21
+
22
+ type CliIo = {
23
+ writeLine: (text: string) => void
24
+ writeError: (text: string) => void
25
+ }
26
+
27
+ function makeCliIo(): CliIo {
28
+ return {
29
+ writeLine: (text) => output.write(`${text}\n`),
30
+ writeError: (text) => process.stderr.write(`${text}\n`),
31
+ }
32
+ }
33
+
34
+ function injectHostFunctions(session: Session): void {
35
+ const coreEnv = session.getNs('clojure.core')!
36
+
37
+ define(
38
+ 'slurp',
39
+ cljNativeFunction('slurp', (pathVal: CljValue) => {
40
+ const filePath = resolve(valueToString(pathVal))
41
+ if (!existsSync(filePath)) {
42
+ throw new Error(`slurp: file not found: ${filePath}`)
43
+ }
44
+ return cljString(readFileSync(filePath, 'utf8'))
45
+ }),
46
+ coreEnv
47
+ )
48
+
49
+ define(
50
+ 'spit',
51
+ cljNativeFunction('spit', (pathVal: CljValue, content: CljValue) => {
52
+ const filePath = resolve(valueToString(pathVal))
53
+ writeFileSync(filePath, valueToString(content), 'utf8')
54
+ return cljNil()
55
+ }),
56
+ coreEnv
57
+ )
58
+
59
+ define(
60
+ 'load',
61
+ cljNativeFunction('load', (pathVal: CljValue) => {
62
+ const filePath = resolve(valueToString(pathVal))
63
+ if (!existsSync(filePath)) {
64
+ throw new Error(`load: file not found: ${filePath}`)
65
+ }
66
+ const source = readFileSync(filePath, 'utf8')
67
+ const inferred = inferSourceRoot(filePath, source)
68
+ if (inferred) session.addSourceRoot(inferred)
69
+ const loadedNs = session.loadFile(source)
70
+ session.setNs(loadedNs)
71
+ return cljNil()
72
+ }),
73
+ coreEnv
74
+ )
75
+ }
76
+
77
+ export function createCliSession(sourceRoots: string[], io: CliIo): Session {
78
+ const session = createSession({
79
+ output: (text) => io.writeLine(text),
80
+ sourceRoots,
81
+ readFile: (filePath) => readFileSync(filePath, 'utf8'),
82
+ })
83
+ injectHostFunctions(session)
84
+ return session
85
+ }
86
+
87
+ function getSourceRoots(filePath?: string): string[] {
88
+ const roots = new Set<string>()
89
+ roots.add(process.cwd())
90
+ if (filePath) {
91
+ roots.add(dirname(filePath))
92
+ }
93
+ return [...roots]
94
+ }
95
+
96
+ function printUsage(io: CliIo) {
97
+ io.writeLine('Usage:')
98
+ io.writeLine(' conjure repl')
99
+ io.writeLine(' conjure run <file.clj>')
100
+ io.writeLine(' conjure nrepl-server [--port <number>] [--host <string>]')
101
+ }
102
+
103
+ export function runFile(fileArg: string, io: CliIo = makeCliIo()): number {
104
+ const filePath = resolve(fileArg)
105
+ if (!existsSync(filePath)) {
106
+ io.writeError(`File not found: ${fileArg}`)
107
+ return 1
108
+ }
109
+
110
+ try {
111
+ const source = readFileSync(filePath, 'utf8')
112
+ const inferredRoot = inferSourceRoot(filePath, source)
113
+ const sourceRoots = inferredRoot
114
+ ? [...new Set([inferredRoot, ...getSourceRoots(filePath)])]
115
+ : getSourceRoots(filePath)
116
+
117
+ const session = createCliSession(sourceRoots, io)
118
+ session.loadFile(source)
119
+
120
+ const nsName = extractNsName(source)
121
+ if (nsName) {
122
+ session.setNs(nsName)
123
+ }
124
+
125
+ return 0
126
+ } catch (error) {
127
+ io.writeError(error instanceof Error ? error.message : String(error))
128
+ return 1
129
+ }
130
+ }
131
+
132
+ function shouldExitRepl(source: string): boolean {
133
+ const trimmed = source.trim()
134
+ return trimmed === '(exit)'
135
+ }
136
+
137
+ function evaluateReplLine(session: Session, line: string, io: CliIo): boolean {
138
+ const trimmed = line.trim()
139
+ if (!trimmed) return true
140
+ if (shouldExitRepl(trimmed)) return false
141
+
142
+ try {
143
+ const result = session.evaluate(trimmed)
144
+ io.writeLine(printString(result))
145
+ } catch (error) {
146
+ io.writeError(error instanceof Error ? error.message : String(error))
147
+ }
148
+
149
+ return true
150
+ }
151
+
152
+ async function startInteractiveRepl(
153
+ session: Session,
154
+ io: CliIo
155
+ ): Promise<number> {
156
+ const rl = createInterface({ input, output })
157
+ try {
158
+ while (true) {
159
+ let line: string
160
+ try {
161
+ line = await rl.question(`${session.currentNs}=> `)
162
+ } catch {
163
+ break
164
+ }
165
+
166
+ if (!evaluateReplLine(session, line, io)) break
167
+ }
168
+
169
+ return 0
170
+ } finally {
171
+ rl.close()
172
+ }
173
+ }
174
+
175
+ async function startStreamRepl(session: Session, io: CliIo): Promise<number> {
176
+ const rl = createInterface({ input, output: process.stderr, terminal: false })
177
+
178
+ try {
179
+ for await (const line of rl) {
180
+ if (!evaluateReplLine(session, line, io)) break
181
+ }
182
+
183
+ return 0
184
+ } finally {
185
+ rl.close()
186
+ }
187
+ }
188
+
189
+ export async function startRepl(io: CliIo = makeCliIo()): Promise<number> {
190
+ const session = createCliSession(getSourceRoots(), io)
191
+
192
+ io.writeLine(`Conjure v${VERSION}`)
193
+ io.writeLine('Type (exit) to exit the REPL.')
194
+
195
+ if (!input.isTTY) {
196
+ return startStreamRepl(session, io)
197
+ }
198
+
199
+ return startInteractiveRepl(session, io)
200
+ }
201
+
202
+ function parseNreplArgs(args: string[]): { port: number; host: string } {
203
+ let port = 7888
204
+ let host = '127.0.0.1'
205
+ for (let i = 0; i < args.length; i++) {
206
+ if (args[i] === '--port' && args[i + 1]) {
207
+ port = parseInt(args[++i], 10)
208
+ } else if (args[i] === '--host' && args[i + 1]) {
209
+ host = args[++i]
210
+ }
211
+ }
212
+ return { port, host }
213
+ }
214
+
215
+ export async function runCli(
216
+ args: string[],
217
+ io: CliIo = makeCliIo()
218
+ ): Promise<number> {
219
+ const [command, ...rest] = args
220
+
221
+ if (!command || command === 'repl') {
222
+ return startRepl(io)
223
+ }
224
+
225
+ if (command === 'run') {
226
+ const [fileArg] = rest
227
+ if (!fileArg) {
228
+ printUsage(io)
229
+ return 1
230
+ }
231
+ return runFile(fileArg, io)
232
+ }
233
+
234
+ if (command === 'nrepl-server') {
235
+ const { port, host } = parseNreplArgs(rest)
236
+ const sourceRoots = discoverSourceRoots(process.cwd())
237
+ startNreplServer({ port, host, sourceRoots })
238
+ // Keep the process alive; the TCP server holds the event loop open.
239
+ return new Promise(() => {})
240
+ }
241
+
242
+ printUsage(io)
243
+ return 1
244
+ }
245
+
246
+ const entryPath = fileURLToPath(import.meta.url)
247
+ if (process.argv[1] && resolve(process.argv[1]) === entryPath) {
248
+ const exitCode = await runCli(process.argv.slice(2))
249
+ process.exitCode = exitCode
250
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, resolve, join } from 'node:path'
3
+ import { extractNsName } from '../vite-plugin-clj/namespace-utils'
4
+
5
+ /**
6
+ * Given an absolute file path and the source it contains, infer the source
7
+ * root directory by stripping the namespace-derived suffix from the path.
8
+ *
9
+ * Example:
10
+ * filePath = "/home/user/project/src/my/app.clj"
11
+ * ns = "my.app"
12
+ * → "/home/user/project/src"
13
+ */
14
+ export function inferSourceRoot(filePath: string, source: string): string | null {
15
+ const nsName = extractNsName(source)
16
+ if (!nsName) return null
17
+
18
+ const normalizedPath = filePath.replace(/\\/g, '/')
19
+ const nsSuffix = `/${nsName.replace(/\./g, '/')}.clj`
20
+ if (!normalizedPath.endsWith(nsSuffix)) {
21
+ return null
22
+ }
23
+
24
+ return normalizedPath.slice(0, -nsSuffix.length) || '/'
25
+ }
26
+
27
+ /**
28
+ * Walk upward from `startDir` looking for a `package.json` with a `"conjure"` key.
29
+ * If found, resolve `conjure.sourceRoots` to absolute paths relative to the
30
+ * package.json location and return them.
31
+ *
32
+ * Falls back to `[startDir]` if no config is found.
33
+ */
34
+ export function discoverSourceRoots(startDir: string): string[] {
35
+ let dir = resolve(startDir)
36
+ const root = resolve('/')
37
+
38
+ while (true) {
39
+ const pkgPath = join(dir, 'package.json')
40
+ if (existsSync(pkgPath)) {
41
+ try {
42
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
43
+ if (pkg.conjure && Array.isArray(pkg.conjure.sourceRoots)) {
44
+ const roots = (pkg.conjure.sourceRoots as string[]).map((r) =>
45
+ resolve(dir, r)
46
+ )
47
+ return roots.length > 0 ? roots : [startDir]
48
+ }
49
+ } catch {
50
+ // Malformed JSON — skip and keep walking
51
+ }
52
+ }
53
+
54
+ if (dir === root) break
55
+ dir = dirname(dir)
56
+ }
57
+
58
+ return [startDir]
59
+ }
@@ -0,0 +1,393 @@
1
+ import * as net from 'net'
2
+ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+ import { BDecoderStream, BEncoderStream } from './bencode'
5
+ import {
6
+ createSession,
7
+ createSessionFromSnapshot,
8
+ printString,
9
+ snapshotSession,
10
+ define,
11
+ cljNativeFunction,
12
+ cljNil,
13
+ cljString,
14
+ valueToString,
15
+ type Session,
16
+ type SessionSnapshot,
17
+ type CljValue,
18
+ } from '../core'
19
+ import { inferSourceRoot } from './nrepl-utils'
20
+ import { VERSION } from './version'
21
+
22
+ const CONJURE_VERSION = VERSION
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ type NreplMessage = Record<string, unknown>
29
+
30
+ type ManagedSession = {
31
+ id: string
32
+ session: Session
33
+ /** Mutable: updated to the current eval message id before each eval call */
34
+ currentMsgId: string
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Session helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function makeSessionId(): string {
42
+ return crypto.randomUUID()
43
+ }
44
+
45
+ function injectHostFunctions(session: Session): void {
46
+ const coreEnv = session.getNs('clojure.core')!
47
+
48
+ define(
49
+ 'slurp',
50
+ cljNativeFunction('slurp', (pathVal: CljValue) => {
51
+ const filePath = resolve(valueToString(pathVal))
52
+ if (!existsSync(filePath)) {
53
+ throw new Error(`slurp: file not found: ${filePath}`)
54
+ }
55
+ return cljString(readFileSync(filePath, 'utf8'))
56
+ }),
57
+ coreEnv
58
+ )
59
+
60
+ define(
61
+ 'spit',
62
+ cljNativeFunction('spit', (pathVal: CljValue, content: CljValue) => {
63
+ const filePath = resolve(valueToString(pathVal))
64
+ writeFileSync(filePath, valueToString(content), 'utf8')
65
+ return cljNil()
66
+ }),
67
+ coreEnv
68
+ )
69
+
70
+ define(
71
+ 'load',
72
+ cljNativeFunction('load', (pathVal: CljValue) => {
73
+ const filePath = resolve(valueToString(pathVal))
74
+ if (!existsSync(filePath)) {
75
+ throw new Error(`load: file not found: ${filePath}`)
76
+ }
77
+ const source = readFileSync(filePath, 'utf8')
78
+ const inferred = inferSourceRoot(filePath, source)
79
+ if (inferred) session.addSourceRoot(inferred)
80
+ const loadedNs = session.loadFile(source)
81
+ session.setNs(loadedNs)
82
+ return cljNil()
83
+ }),
84
+ coreEnv
85
+ )
86
+ }
87
+
88
+ function createManagedSession(
89
+ id: string,
90
+ snapshot: SessionSnapshot,
91
+ encoder: BEncoderStream
92
+ ): ManagedSession {
93
+ let currentMsgId = ''
94
+
95
+ const session = createSessionFromSnapshot(snapshot, {
96
+ output: (text) => {
97
+ send(encoder, { id: currentMsgId, session: id, out: text })
98
+ },
99
+ readFile: (filePath) => readFileSync(filePath, 'utf8'),
100
+ })
101
+
102
+ injectHostFunctions(session)
103
+
104
+ return {
105
+ id,
106
+ session,
107
+ get currentMsgId() {
108
+ return currentMsgId
109
+ },
110
+ set currentMsgId(v) {
111
+ currentMsgId = v
112
+ },
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Protocol helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function send(encoder: BEncoderStream, msg: NreplMessage) {
121
+ encoder.write(msg)
122
+ }
123
+
124
+ function done(
125
+ encoder: BEncoderStream,
126
+ id: string,
127
+ sessionId: string | undefined,
128
+ extra: NreplMessage = {}
129
+ ) {
130
+ // If the caller already sets status (e.g. ['eval-error', 'done']), preserve it.
131
+ send(encoder, {
132
+ id,
133
+ ...(sessionId ? { session: sessionId } : {}),
134
+ status: ['done'],
135
+ ...extra,
136
+ })
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Op handlers
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function handleClone(
144
+ msg: NreplMessage,
145
+ sessions: Map<string, ManagedSession>,
146
+ snapshot: SessionSnapshot,
147
+ encoder: BEncoderStream
148
+ ) {
149
+ const id = (msg['id'] as string) ?? ''
150
+ const newId = makeSessionId()
151
+ const managed = createManagedSession(newId, snapshot, encoder)
152
+ sessions.set(newId, managed)
153
+ done(encoder, id, undefined, { 'new-session': newId })
154
+ }
155
+
156
+ function handleDescribe(msg: NreplMessage, encoder: BEncoderStream) {
157
+ const id = (msg['id'] as string) ?? ''
158
+ const sessionId = msg['session'] as string | undefined
159
+ done(encoder, id, sessionId, {
160
+ ops: {
161
+ eval: {},
162
+ clone: {},
163
+ close: {},
164
+ complete: {},
165
+ describe: {},
166
+ 'load-file': {},
167
+ },
168
+ versions: {
169
+ conjure: { 'version-string': CONJURE_VERSION },
170
+ },
171
+ })
172
+ }
173
+
174
+ function handleEval(
175
+ msg: NreplMessage,
176
+ managed: ManagedSession,
177
+ encoder: BEncoderStream
178
+ ) {
179
+ const id = (msg['id'] as string) ?? ''
180
+ const code = (msg['code'] as string) ?? ''
181
+
182
+ managed.currentMsgId = id
183
+
184
+ try {
185
+ const result = managed.session.evaluate(code)
186
+ done(encoder, id, managed.id, {
187
+ value: printString(result),
188
+ ns: managed.session.currentNs,
189
+ })
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error)
192
+ done(encoder, id, managed.id, {
193
+ ex: message,
194
+ err: message + '\n',
195
+ ns: managed.session.currentNs,
196
+ status: ['eval-error', 'done'],
197
+ })
198
+ }
199
+ }
200
+
201
+ function handleLoadFile(
202
+ msg: NreplMessage,
203
+ managed: ManagedSession,
204
+ encoder: BEncoderStream
205
+ ) {
206
+ const id = (msg['id'] as string) ?? ''
207
+ const source = (msg['file'] as string) ?? ''
208
+ const fileName = (msg['file-name'] as string) ?? ''
209
+ const filePath = (msg['file-path'] as string) ?? ''
210
+
211
+ managed.currentMsgId = id
212
+
213
+ try {
214
+ // Dynamically infer source root from the file path + ns declaration.
215
+ // This is the zero-config path: Calva sends the absolute path, and we
216
+ // derive the root so sibling namespaces resolve via require.
217
+ if (filePath) {
218
+ const inferred = inferSourceRoot(filePath, source)
219
+ if (inferred) {
220
+ managed.session.addSourceRoot(inferred)
221
+ }
222
+ }
223
+
224
+ const nsHint =
225
+ fileName.replace(/\.clj$/, '').replace(/\//g, '.') || undefined
226
+ const loadedNs = managed.session.loadFile(source, nsHint)
227
+
228
+ // Switch the session's active namespace to the one declared in the loaded
229
+ // file. Without this, subsequent eval ops land in the wrong namespace and
230
+ // can't see the defs that were just loaded.
231
+ managed.session.setNs(loadedNs)
232
+
233
+ done(encoder, id, managed.id, {
234
+ value: 'nil',
235
+ ns: managed.session.currentNs,
236
+ })
237
+ } catch (error) {
238
+ done(encoder, id, managed.id, {
239
+ ex: error instanceof Error ? error.message : String(error),
240
+ ns: managed.session.currentNs,
241
+ status: ['eval-error', 'done'],
242
+ })
243
+ }
244
+ }
245
+
246
+ function handleComplete(
247
+ msg: NreplMessage,
248
+ managed: ManagedSession,
249
+ encoder: BEncoderStream
250
+ ) {
251
+ const id = (msg['id'] as string) ?? ''
252
+ const prefix = (msg['prefix'] as string) ?? ''
253
+ const nsName = msg['ns'] as string | undefined
254
+
255
+ const names = managed.session.getCompletions(prefix, nsName)
256
+ const completions = names.map((c) => ({
257
+ candidate: c,
258
+ type: 'var',
259
+ ns: managed.session.currentNs,
260
+ }))
261
+
262
+ done(encoder, id, managed.id, { completions })
263
+ }
264
+
265
+ function handleClose(
266
+ msg: NreplMessage,
267
+ sessions: Map<string, ManagedSession>,
268
+ encoder: BEncoderStream
269
+ ) {
270
+ // cider sends a close message with an id
271
+ const id = (msg['id'] as string) ?? ''
272
+ const sessionId = (msg['session'] as string) ?? ''
273
+ sessions.delete(sessionId)
274
+ send(encoder, { id, session: sessionId, status: ['done'] })
275
+ }
276
+
277
+ function handleUnknown(msg: NreplMessage, encoder: BEncoderStream) {
278
+ const id = (msg['id'] as string) ?? ''
279
+ send(encoder, { id, status: ['unknown-op', 'done'] })
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Per-connection dispatcher
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function handleMessage(
287
+ msg: NreplMessage,
288
+ sessions: Map<string, ManagedSession>,
289
+ snapshot: SessionSnapshot,
290
+ encoder: BEncoderStream,
291
+ defaultSession: ManagedSession
292
+ ) {
293
+ const op = msg['op'] as string
294
+ const sessionId = msg['session'] as string | undefined
295
+ const managed = sessionId
296
+ ? (sessions.get(sessionId) ?? defaultSession)
297
+ : defaultSession
298
+
299
+ switch (op) {
300
+ case 'clone':
301
+ handleClone(msg, sessions, snapshot, encoder)
302
+ break
303
+ case 'describe':
304
+ handleDescribe(msg, encoder)
305
+ break
306
+ case 'eval':
307
+ handleEval(msg, managed, encoder)
308
+ break
309
+ case 'load-file':
310
+ handleLoadFile(msg, managed, encoder)
311
+ break
312
+ case 'complete':
313
+ handleComplete(msg, managed, encoder)
314
+ break
315
+ case 'close':
316
+ handleClose(msg, sessions, encoder)
317
+ break
318
+ default:
319
+ handleUnknown(msg, encoder)
320
+ }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Server startup
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export type NreplServerOptions = {
328
+ port?: number
329
+ host?: string
330
+ sourceRoots?: string[]
331
+ }
332
+
333
+ export function startNreplServer(options: NreplServerOptions = {}): net.Server {
334
+ const port = options.port ?? 7888
335
+ const host = options.host ?? '127.0.0.1'
336
+
337
+ // Build a warm snapshot once — all clones skip the core bootstrap.
338
+ // Source roots from config discovery are baked in so every cloned session inherits them.
339
+ const warmSession = createSession({
340
+ sourceRoots: options.sourceRoots,
341
+ readFile: (filePath) => readFileSync(filePath, 'utf8'),
342
+ })
343
+ const snapshot = snapshotSession(warmSession)
344
+
345
+ const server = net.createServer((socket) => {
346
+ const encoder = new BEncoderStream()
347
+ const decoder = new BDecoderStream()
348
+
349
+ encoder.pipe(socket)
350
+ socket.pipe(decoder)
351
+
352
+ const sessions = new Map<string, ManagedSession>()
353
+
354
+ // A default session for session-less messages (e.g. Calva's initial eval)
355
+ const defaultId = makeSessionId()
356
+ const defaultSession = createManagedSession(defaultId, snapshot, encoder)
357
+ sessions.set(defaultId, defaultSession)
358
+
359
+ decoder.on('data', (msg: NreplMessage) => {
360
+ handleMessage(msg, sessions, snapshot, encoder, defaultSession)
361
+ })
362
+
363
+ socket.on('error', () => {
364
+ // Connection dropped — nothing to clean up beyond GC
365
+ })
366
+
367
+ socket.on('close', () => {
368
+ sessions.clear()
369
+ })
370
+ })
371
+
372
+ const portFile = join(process.cwd(), '.nrepl-port')
373
+
374
+ server.listen(port, host, () => {
375
+ writeFileSync(portFile, String(port), 'utf8')
376
+ process.stdout.write(`Conjure nREPL server v${VERSION} started on port ${port}\n`)
377
+ })
378
+
379
+ const cleanup = () => {
380
+ if (existsSync(portFile)) unlinkSync(portFile)
381
+ }
382
+ process.on('exit', cleanup)
383
+ process.on('SIGINT', () => {
384
+ cleanup()
385
+ process.exit(0)
386
+ })
387
+ process.on('SIGTERM', () => {
388
+ cleanup()
389
+ process.exit(0)
390
+ })
391
+
392
+ return server
393
+ }
@@ -0,0 +1,4 @@
1
+ // This file is auto-generated by scripts/gen-version.mjs
2
+ // Do not edit manually - changes will be overwritten
3
+
4
+ export const VERSION = '0.0.1'