clojurific 0.12.3 → 0.12.4
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/README.md +9 -0
- package/cljf/cli.js +9 -0
- package/cljf/repl.js +335 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,9 @@ counts Clojurific's releases, starting from 1.
|
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
|
+
`npm create clojurific@latest` scaffolds a project (Vanilla, Reagent or UIx)
|
|
20
|
+
like the one below, installs it and starts the dev server.
|
|
21
|
+
|
|
19
22
|
Dependencies and source paths come from the project's `deps.edn`:
|
|
20
23
|
|
|
21
24
|
```clojure
|
|
@@ -50,8 +53,14 @@ or with the command line:
|
|
|
50
53
|
npx cljf build '{:main my.app :output-dir "out"}'
|
|
51
54
|
npx cljf -A:test watch @cljs.edn
|
|
52
55
|
npx cljf classpath
|
|
56
|
+
npx cljf repl
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
`cljf repl` is a ClojureScript REPL evaluating in the pages the dev server
|
|
60
|
+
runs, through its nREPL server: `cljs({ outputDir: 'target/cljs',
|
|
61
|
+
compilerOptions: { repl: { 'nrepl-port': 0 } } })` with `nrepl/nrepl` in
|
|
62
|
+
`deps.edn`.
|
|
63
|
+
|
|
55
64
|
`cljf` resolves `deps.edn` with tools.deps, adds the compiler, and removes
|
|
56
65
|
stock ClojureScript (`org.clojure/clojurescript`) that libraries depend on.
|
|
57
66
|
Dependencies are downloaded into `~/.m2/repository`, which the Clojure CLI
|
package/cljf/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { LauncherError } from './errors.js';
|
|
7
7
|
import { classpath, compilerCommand, findJava, setupJava, version } from './launcher.js';
|
|
8
|
+
import { repl } from './repl.js';
|
|
8
9
|
|
|
9
10
|
const usage = `Usage: cljf [options] <command> [arguments]
|
|
10
11
|
|
|
@@ -13,6 +14,9 @@ Commands:
|
|
|
13
14
|
and :profile keywords (defaults to :release)
|
|
14
15
|
watch [options...] compile, then recompile on changes (defaults to :dev)
|
|
15
16
|
classpath print the project's classpath
|
|
17
|
+
repl [--port <port>] a ClojureScript REPL into the pages running the dev
|
|
18
|
+
server's (or watch's) build, through its nREPL server
|
|
19
|
+
(:repl {:nrepl-port 0}, port from .nrepl-port)
|
|
16
20
|
setup-java [version] download Eclipse Temurin (the latest LTS by default)
|
|
17
21
|
into cljf's cache, for machines without Java
|
|
18
22
|
|
|
@@ -73,6 +77,11 @@ async function main() {
|
|
|
73
77
|
console.log(cp.classpath.join(path.delimiter));
|
|
74
78
|
break;
|
|
75
79
|
}
|
|
80
|
+
case 'repl': {
|
|
81
|
+
const i = args.indexOf('--port');
|
|
82
|
+
process.exit(await repl({ port: i >= 0 ? Number(args[i + 1]) : undefined }));
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
76
85
|
case 'setup-java': {
|
|
77
86
|
const { java } = await setupJava({ version: args[0], log });
|
|
78
87
|
console.log(java);
|
package/cljf/repl.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// cljf repl: a ClojureScript REPL in the terminal, evaluating in the pages
|
|
2
|
+
// running a watched build. It's an nREPL client of the watcher's nREPL server
|
|
3
|
+
// (:repl {:nrepl-port 0}, see cljs.esm.repl.nrepl), whose session it
|
|
4
|
+
// switches to ClojureScript with (cljs.esm.repl/repl).
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import readline from 'node:readline';
|
|
10
|
+
import { LauncherError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
// bencode, nREPL's encoding: dicts, lists, integers and (UTF-8) strings
|
|
13
|
+
|
|
14
|
+
export function bencode(v) {
|
|
15
|
+
if (typeof v === 'number') return Buffer.from(`i${Math.trunc(v)}e`);
|
|
16
|
+
if (typeof v === 'string' || Buffer.isBuffer(v)) {
|
|
17
|
+
const bytes = Buffer.isBuffer(v) ? v : Buffer.from(v);
|
|
18
|
+
return Buffer.concat([Buffer.from(`${bytes.length}:`), bytes]);
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(v)) return Buffer.concat([Buffer.from('l'), ...v.map(bencode), Buffer.from('e')]);
|
|
21
|
+
const keys = Object.keys(v).filter(k => v[k] !== undefined).sort();
|
|
22
|
+
return Buffer.concat([Buffer.from('d'), ...keys.flatMap(k => [bencode(k), bencode(v[k])]), Buffer.from('e')]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INCOMPLETE = Symbol('incomplete');
|
|
26
|
+
|
|
27
|
+
// The value at buf[i], [value, next index], or INCOMPLETE
|
|
28
|
+
function decodeAt(buf, i) {
|
|
29
|
+
if (i >= buf.length) return INCOMPLETE;
|
|
30
|
+
const c = String.fromCharCode(buf[i]);
|
|
31
|
+
if (c === 'i') {
|
|
32
|
+
const end = buf.indexOf('e', i);
|
|
33
|
+
return end < 0 ? INCOMPLETE : [Number(buf.toString('latin1', i + 1, end)), end + 1];
|
|
34
|
+
}
|
|
35
|
+
if (c === 'l' || c === 'd') {
|
|
36
|
+
const items = [];
|
|
37
|
+
let j = i + 1;
|
|
38
|
+
while (j < buf.length && buf[j] !== 0x65) {
|
|
39
|
+
const item = decodeAt(buf, j);
|
|
40
|
+
if (item === INCOMPLETE) return INCOMPLETE;
|
|
41
|
+
items.push(item[0]);
|
|
42
|
+
j = item[1];
|
|
43
|
+
}
|
|
44
|
+
if (j >= buf.length) return INCOMPLETE;
|
|
45
|
+
if (c === 'l') return [items, j + 1];
|
|
46
|
+
const dict = {};
|
|
47
|
+
for (let k = 0; k < items.length; k += 2) dict[items[k]] = items[k + 1];
|
|
48
|
+
return [dict, j + 1];
|
|
49
|
+
}
|
|
50
|
+
if (c >= '0' && c <= '9') {
|
|
51
|
+
const colon = buf.indexOf(':', i);
|
|
52
|
+
if (colon < 0) return INCOMPLETE;
|
|
53
|
+
const start = colon + 1;
|
|
54
|
+
const end = start + Number(buf.toString('latin1', i, colon));
|
|
55
|
+
return end > buf.length ? INCOMPLETE : [buf.toString('utf8', start, end), end];
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`bencode: unexpected ${JSON.stringify(c)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Decodes the values of a byte stream, as chunks arrive. */
|
|
61
|
+
export class Decoder {
|
|
62
|
+
#buf = Buffer.alloc(0);
|
|
63
|
+
|
|
64
|
+
/** The values chunk completes. */
|
|
65
|
+
push(chunk) {
|
|
66
|
+
this.#buf = Buffer.concat([this.#buf, chunk]);
|
|
67
|
+
const values = [];
|
|
68
|
+
for (;;) {
|
|
69
|
+
const decoded = decodeAt(this.#buf, 0);
|
|
70
|
+
if (decoded === INCOMPLETE) return values;
|
|
71
|
+
values.push(decoded[0]);
|
|
72
|
+
this.#buf = this.#buf.subarray(decoded[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether code's forms are all closed, so that the REPL evaluates it rather
|
|
79
|
+
* than reading another line: no open (, [ or {, string or regex. Extra
|
|
80
|
+
* closing brackets are the reader's to report.
|
|
81
|
+
*/
|
|
82
|
+
export function complete(code) {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
for (let i = 0; i < code.length; i++) {
|
|
85
|
+
const c = code[i];
|
|
86
|
+
if (c === ';') {
|
|
87
|
+
while (i < code.length && code[i] !== '\n') i++;
|
|
88
|
+
} else if (c === '\\') {
|
|
89
|
+
i++;
|
|
90
|
+
} else if (c === '"') {
|
|
91
|
+
for (i++; i < code.length && code[i] !== '"'; i++) if (code[i] === '\\') i++;
|
|
92
|
+
if (i >= code.length) return false;
|
|
93
|
+
} else if ('([{'.includes(c)) {
|
|
94
|
+
depth++;
|
|
95
|
+
} else if (')]}'.includes(c)) {
|
|
96
|
+
depth--;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return depth <= 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An nREPL connection: requests whose responses end with status done. */
|
|
103
|
+
export class Connection {
|
|
104
|
+
#socket;
|
|
105
|
+
#ids = 0;
|
|
106
|
+
#pending = new Map();
|
|
107
|
+
|
|
108
|
+
constructor(socket) {
|
|
109
|
+
this.#socket = socket;
|
|
110
|
+
const decoder = new Decoder();
|
|
111
|
+
socket.on('data', chunk => {
|
|
112
|
+
for (const msg of decoder.push(chunk)) {
|
|
113
|
+
const request = this.#pending.get(msg.id);
|
|
114
|
+
if (!request) continue;
|
|
115
|
+
request.onResponse(msg);
|
|
116
|
+
if (msg.status?.includes('done')) {
|
|
117
|
+
this.#pending.delete(msg.id);
|
|
118
|
+
request.resolve(msg);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A message id for request. */
|
|
125
|
+
newId() {
|
|
126
|
+
return String(++this.#ids);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sends msg (with an id from newId, or a new one), calls onResponse with
|
|
131
|
+
* its responses; resolves to the last.
|
|
132
|
+
*/
|
|
133
|
+
request(msg, onResponse = () => {}) {
|
|
134
|
+
const id = msg.id ?? this.newId();
|
|
135
|
+
return new Promise(resolve => {
|
|
136
|
+
this.#pending.set(id, { onResponse, resolve });
|
|
137
|
+
this.#socket.write(bencode({ ...msg, id }));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
close() {
|
|
142
|
+
this.#socket.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function connect(port, host) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const socket = net.connect(port, host);
|
|
149
|
+
socket.once('connect', () => {
|
|
150
|
+
socket.off('error', reject);
|
|
151
|
+
resolve(socket);
|
|
152
|
+
});
|
|
153
|
+
socket.once('error', reject);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Connects to the nREPL server on port, or the one in cwd's .nrepl-port,
|
|
159
|
+
* waiting for it to start (the dev server writes the file once it built).
|
|
160
|
+
*/
|
|
161
|
+
export async function connectToServer({ cwd = process.cwd(), port, host = '127.0.0.1', log = () => {}, interval = 500 } = {}) {
|
|
162
|
+
const portFile = path.join(cwd, '.nrepl-port');
|
|
163
|
+
let waiting = false;
|
|
164
|
+
for (;;) {
|
|
165
|
+
const p = port ?? (existsSync(portFile) ? Number((await fs.readFile(portFile, 'utf8')).trim()) : null);
|
|
166
|
+
if (p) {
|
|
167
|
+
try {
|
|
168
|
+
return await connect(p, host);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (port) throw new LauncherError(`cljf repl: no nREPL server on ${host}:${port}: ${e.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!waiting) {
|
|
174
|
+
log(`; waiting for the nREPL server of the dev server (npm run dev) or cljf watch, with :repl {:nrepl-port 0},\n; to write ${portFile}`);
|
|
175
|
+
waiting = true;
|
|
176
|
+
}
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readHistory(file) {
|
|
182
|
+
try {
|
|
183
|
+
return (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean).reverse();
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The REPL: reads forms from input, evaluates them in the session switched
|
|
191
|
+
* to ClojureScript, writes their output and values to output (errors and
|
|
192
|
+
* warnings to errorOutput). Resolves to the exit code once input ends or
|
|
193
|
+
* :cljs/quit.
|
|
194
|
+
*/
|
|
195
|
+
export async function repl({
|
|
196
|
+
cwd = process.cwd(),
|
|
197
|
+
port,
|
|
198
|
+
host,
|
|
199
|
+
input = process.stdin,
|
|
200
|
+
output = process.stdout,
|
|
201
|
+
errorOutput = process.stderr,
|
|
202
|
+
terminal = Boolean(input.isTTY && output.isTTY),
|
|
203
|
+
historyFile = path.join(cwd, '.cljf', 'repl-history'),
|
|
204
|
+
} = {}) {
|
|
205
|
+
const socket = await connectToServer({ cwd, port, host, log: line => errorOutput.write(`${line}\n`) });
|
|
206
|
+
const conn = new Connection(socket);
|
|
207
|
+
let closing = false;
|
|
208
|
+
const closed = new Promise(resolve => socket.on('close', resolve));
|
|
209
|
+
socket.on('error', () => {});
|
|
210
|
+
|
|
211
|
+
const write = (stream, text) => text && stream.write(text);
|
|
212
|
+
const { 'new-session': session } = await conn.request({ op: 'clone' });
|
|
213
|
+
let failed = false;
|
|
214
|
+
await conn.request({ op: 'eval', session, code: '(cljs.esm.repl/repl)' }, msg => {
|
|
215
|
+
write(output, msg.out);
|
|
216
|
+
write(errorOutput, msg.err);
|
|
217
|
+
if (msg.ex) failed = true;
|
|
218
|
+
});
|
|
219
|
+
if (failed) {
|
|
220
|
+
conn.close();
|
|
221
|
+
throw new LauncherError('cljf repl: the nREPL server has no ClojureScript REPL (cljs.esm.repl), is it the dev server\'s?');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const history = terminal ? await readHistory(historyFile) : [];
|
|
225
|
+
const rl = readline.createInterface({ input, output, terminal, history, historySize: 1000, removeHistoryDuplicates: true });
|
|
226
|
+
if (terminal) {
|
|
227
|
+
rl.on('history', lines => {
|
|
228
|
+
fs.mkdir(path.dirname(historyFile), { recursive: true })
|
|
229
|
+
.then(() => fs.writeFile(historyFile, `${[...lines].reverse().join('\n')}\n`))
|
|
230
|
+
.catch(() => {});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let ns = 'cljs.user';
|
|
235
|
+
let pending = '';
|
|
236
|
+
let evaluating = null;
|
|
237
|
+
let interrupted = false;
|
|
238
|
+
const prompt = () => {
|
|
239
|
+
if (!terminal) return;
|
|
240
|
+
rl.setPrompt(pending ? `${' '.repeat(Math.max(0, ns.length - 1))}#_=> ` : `${ns}=> `);
|
|
241
|
+
rl.prompt();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const done = new Promise(resolve => {
|
|
245
|
+
const quit = code => {
|
|
246
|
+
closing = true;
|
|
247
|
+
rl.close();
|
|
248
|
+
// the server forgets the session, unless it doesn't answer
|
|
249
|
+
const timeout = new Promise(r => setTimeout(r, 1000).unref());
|
|
250
|
+
Promise.race([conn.request({ op: 'close', session }), timeout]).finally(() => {
|
|
251
|
+
conn.close();
|
|
252
|
+
resolve(code);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
closed.then(() => {
|
|
256
|
+
if (closing) return;
|
|
257
|
+
errorOutput.write('\n; the nREPL server closed the connection\n');
|
|
258
|
+
closing = true;
|
|
259
|
+
rl.close();
|
|
260
|
+
resolve(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
rl.on('SIGINT', () => {
|
|
264
|
+
if (evaluating) {
|
|
265
|
+
conn.request({ op: 'interrupt', session, 'interrupt-id': evaluating });
|
|
266
|
+
} else if (pending || rl.line) {
|
|
267
|
+
pending = '';
|
|
268
|
+
interrupted = false;
|
|
269
|
+
rl.write(null, { ctrl: true, name: 'u' });
|
|
270
|
+
output.write('\n');
|
|
271
|
+
prompt();
|
|
272
|
+
} else if (interrupted) {
|
|
273
|
+
quit(0);
|
|
274
|
+
} else {
|
|
275
|
+
interrupted = true;
|
|
276
|
+
output.write('\n; Ctrl-C again, Ctrl-D or :cljs/quit to quit\n');
|
|
277
|
+
prompt();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// lines are read one at a time: a form waits for the one before
|
|
282
|
+
const lines = [];
|
|
283
|
+
let busy = false;
|
|
284
|
+
const next = async () => {
|
|
285
|
+
if (busy) return;
|
|
286
|
+
busy = true;
|
|
287
|
+
while (lines.length) {
|
|
288
|
+
const line = lines.shift();
|
|
289
|
+
if (line === null) {
|
|
290
|
+
quit(0);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
interrupted = false;
|
|
294
|
+
pending += `${line}\n`;
|
|
295
|
+
if (!complete(pending)) {
|
|
296
|
+
prompt();
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const code = pending;
|
|
300
|
+
pending = '';
|
|
301
|
+
if (!code.trim()) {
|
|
302
|
+
prompt();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (code.trim() === ':cljs/quit') {
|
|
306
|
+
quit(0);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
evaluating = conn.newId();
|
|
310
|
+
await conn.request({ op: 'eval', session, code, ns, id: evaluating }, msg => {
|
|
311
|
+
write(output, msg.out);
|
|
312
|
+
write(errorOutput, msg.err);
|
|
313
|
+
if (msg.value !== undefined) output.write(`${msg.value}\n`);
|
|
314
|
+
if (msg.ns) ns = msg.ns;
|
|
315
|
+
});
|
|
316
|
+
evaluating = null;
|
|
317
|
+
if (closing) return;
|
|
318
|
+
prompt();
|
|
319
|
+
}
|
|
320
|
+
busy = false;
|
|
321
|
+
};
|
|
322
|
+
rl.on('line', line => {
|
|
323
|
+
lines.push(line);
|
|
324
|
+
next();
|
|
325
|
+
});
|
|
326
|
+
rl.on('close', () => {
|
|
327
|
+
if (closing) return;
|
|
328
|
+
lines.push(null);
|
|
329
|
+
next();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
prompt();
|
|
334
|
+
return done;
|
|
335
|
+
}
|