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.
- package/conjure +0 -0
- package/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/assets/editor.worker-CdQrwHl8.js +26 -0
- package/dist/assets/main-A7ZMId9A.css +1 -0
- package/dist/assets/main-CmI-7epE.js +3137 -0
- package/dist/index.html +195 -0
- package/dist/vite.svg +1 -0
- package/package.json +68 -0
- package/src/bin/__fixtures__/smoke/app/lib.clj +4 -0
- package/src/bin/__fixtures__/smoke/app/main.clj +4 -0
- package/src/bin/__fixtures__/smoke/repl-smoke.ts +12 -0
- package/src/bin/bencode.ts +205 -0
- package/src/bin/cli.ts +250 -0
- package/src/bin/nrepl-utils.ts +59 -0
- package/src/bin/nrepl.ts +393 -0
- package/src/bin/version.ts +4 -0
- package/src/clojure/core.clj +620 -0
- package/src/clojure/core.clj.d.ts +189 -0
- package/src/clojure/demo/math.clj +16 -0
- package/src/clojure/demo/math.clj.d.ts +4 -0
- package/src/clojure/demo.clj +42 -0
- package/src/clojure/demo.clj.d.ts +0 -0
- package/src/clojure/generated/builtin-namespace-registry.ts +14 -0
- package/src/clojure/generated/clojure-core-source.ts +623 -0
- package/src/clojure/generated/clojure-string-source.ts +196 -0
- package/src/clojure/string.clj +192 -0
- package/src/clojure/string.clj.d.ts +25 -0
- package/src/core/assertions.ts +134 -0
- package/src/core/conversions.ts +108 -0
- package/src/core/core-env.ts +58 -0
- package/src/core/env.ts +78 -0
- package/src/core/errors.ts +39 -0
- package/src/core/evaluator/apply.ts +114 -0
- package/src/core/evaluator/arity.ts +174 -0
- package/src/core/evaluator/collections.ts +25 -0
- package/src/core/evaluator/destructure.ts +247 -0
- package/src/core/evaluator/dispatch.ts +73 -0
- package/src/core/evaluator/evaluate.ts +100 -0
- package/src/core/evaluator/expand.ts +79 -0
- package/src/core/evaluator/index.ts +72 -0
- package/src/core/evaluator/quasiquote.ts +87 -0
- package/src/core/evaluator/recur-check.ts +109 -0
- package/src/core/evaluator/special-forms.ts +517 -0
- package/src/core/factories.ts +155 -0
- package/src/core/gensym.ts +9 -0
- package/src/core/index.ts +76 -0
- package/src/core/positions.ts +38 -0
- package/src/core/printer.ts +86 -0
- package/src/core/reader.ts +559 -0
- package/src/core/scanners.ts +93 -0
- package/src/core/session.ts +610 -0
- package/src/core/stdlib/arithmetic.ts +361 -0
- package/src/core/stdlib/atoms.ts +88 -0
- package/src/core/stdlib/collections.ts +784 -0
- package/src/core/stdlib/errors.ts +81 -0
- package/src/core/stdlib/hof.ts +307 -0
- package/src/core/stdlib/meta.ts +48 -0
- package/src/core/stdlib/predicates.ts +240 -0
- package/src/core/stdlib/regex.ts +238 -0
- package/src/core/stdlib/strings.ts +311 -0
- package/src/core/stdlib/transducers.ts +256 -0
- package/src/core/stdlib/utils.ts +287 -0
- package/src/core/tokenizer.ts +437 -0
- package/src/core/transformations.ts +75 -0
- package/src/core/types.ts +258 -0
- package/src/main.ts +1 -0
- package/src/monaco-esm.d.ts +7 -0
- package/src/playground/clojure-tokens.ts +67 -0
- package/src/playground/editor.worker.ts +5 -0
- package/src/playground/find-form.ts +138 -0
- package/src/playground/playground.ts +342 -0
- package/src/playground/samples/00-welcome.clj +385 -0
- package/src/playground/samples/01-collections.clj +191 -0
- package/src/playground/samples/02-higher-order-functions.clj +215 -0
- package/src/playground/samples/03-destructuring.clj +194 -0
- package/src/playground/samples/04-strings-and-regex.clj +202 -0
- package/src/playground/samples/05-error-handling.clj +212 -0
- package/src/repl/repl.ts +116 -0
- package/tsconfig.build.json +10 -0
- 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
|
+
}
|
package/src/bin/nrepl.ts
ADDED
|
@@ -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
|
+
}
|