1const javascript = repl.javascript();
2/** for an editor that only lexes */
3const syntax = { tokenizer: javascript.tokenizer };
4
5/** kinds and text of the tokens that are not whitespace */
6function lex(code: string): string[] {
7 let start = 0;
8 return repl.tokenize(javascript.tokenizer, code).flatMap((token) => {
9 const text = code.slice(start, token.end);
10 start = token.end;
11 return token.kind ? [`${token.kind}:${text}`] : [];
12 });
13}
14
15function stateAfter(code: string): string {
16 return repl.tokenize(javascript.tokenizer, code).at(-1)?.state ?? javascript.tokenizer.start;
17}
18
19describe("javascript tokenizer", () => {
20 test("kinds", () => {
21 assert.deepEqual(lex("const x = await f(1.5e3, 'a\\'b', null) // hi"), [
22 "keyword:const",
23 "variable:x",
24 "punctuation:=",
25 "keyword:await",
26 "variable:f",
27 "punctuation:(",
28 "number:1.5e3",
29 "punctuation:,",
30 "string:'a\\'b'",
31 "punctuation:,",
32 "literal:null",
33 "punctuation:)",
34 "comment:// hi",
35 ]);
36 assert.deepEqual(lex("0xff_ffn .5 1..toString() 1_000"), [
37 "number:0xff_ffn",
38 "number:.5",
39 "number:1.",
40 "punctuation:.",
41 "property:toString",
42 "punctuation:(",
43 "punctuation:)",
44 "number:1_000",
45 ]);
46 assert.deepEqual(lex("a?.b ?? c?.5:d >>>= #priv ...rest"), [
47 "variable:a",
48 "punctuation:?.",
49 "property:b",
50 "punctuation:??",
51 "variable:c",
52 "punctuation:?",
53 "number:.5",
54 "punctuation::",
55 "variable:d",
56 "punctuation:>>>=",
57 "variable:#priv",
58 "punctuation:...",
59 "variable:rest",
60 ]);
61 });
62
63 test("a number takes its exponent along, digits or not", () => {
64 assert.deepEqual(lex("1e+5 2E-"), ["number:1e+5", "number:2E-"]);
65 // so that typing one out never changes a token further back than one
66 const editor = new repl.Editor(syntax);
67 press(editor, "x", "=", "1", "e", "-", "3");
68 assert.deepEqual(editor.tokens, repl.tokenize(javascript.tokenizer, "x=1e-3"));
69 });
70
71 test("a keyword after a dot is a property", () => {
72 assert.deepEqual(lex("promise.catch(x).\n finally"), [
73 "variable:promise",
74 "punctuation:.",
75 "property:catch",
76 "punctuation:(",
77 "variable:x",
78 "punctuation:)",
79 "punctuation:.",
80 "property:finally",
81 ]);
82 });
83
84 test("a slash divides or starts a regular expression", () => {
85 assert.deepEqual(lex("a / b / c"), [
86 "variable:a",
87 "punctuation:/",
88 "variable:b",
89 "punctuation:/",
90 "variable:c",
91 ]);
92 assert.deepEqual(lex("x = /[/\\]]+\\//gi.test(y) / 2"), [
93 "variable:x",
94 "punctuation:=",
95 "regexp:/[/\\]]+\\//gi",
96 "punctuation:.",
97 "property:test",
98 "punctuation:(",
99 "variable:y",
100 "punctuation:)",
101 "punctuation:/",
102 "number:2",
103 ]);
104 assert.deepEqual(lex("return /a/"), ["keyword:return", "regexp:/a/"]);
105 assert.deepEqual(lex("this /a/ i++ / 2"), [
106 "keyword:this",
107 "punctuation:/",
108 "variable:a",
109 "punctuation:/",
110 "variable:i",
111 "punctuation:++",
112 "punctuation:/",
113 "number:2",
114 ]);
115 // unterminated, it ends with the line
116 assert.deepEqual(lex("(/ab\nc)"), [
117 "punctuation:(",
118 "regexp:/ab",
119 "variable:c",
120 "punctuation:)",
121 ]);
122 });
123
124 test("templates nest", () => {
125 assert.deepEqual(lex("`a${ {b: `c${d}`} }e` / 2"), [
126 "string:`",
127 "string:a",
128 "punctuation:${",
129 "punctuation:{",
130 "variable:b",
131 "punctuation::",
132 "string:`",
133 "string:c",
134 "punctuation:${",
135 "variable:d",
136 "punctuation:}",
137 "string:`",
138 "punctuation:}",
139 "punctuation:}",
140 "string:e",
141 "string:`",
142 "punctuation:/",
143 "number:2",
144 ]);
145 assert.deepEqual(lex("`$ \\` \\${x} $`"), ["string:`", "string:$ \\` \\${x} $", "string:`"]);
146 });
147
148 test("the state is what is still open", () => {
149 assert.equal(stateAfter("f({ a: [`x${"), "({[`$r");
150 assert.equal(stateAfter("f({ a: [`x${1}`]})"), "d");
151 assert.equal(stateAfter("x /* a"), "*d");
152 assert.equal(stateAfter("x /* a */"), "d");
153 assert.equal(stateAfter("'abc"), "d");
154 // a bracket that does not match is remembered
155 assert.deepEqual(lex("(]"), ["punctuation:(", "invalid:]"]);
156 assert.equal(stateAfter("(]"), "!(d");
157 assert.equal(stateAfter("} {"), "!{r");
158 });
159});
160
161/** a tokenizer that counts how much lexing was done */
162function counting(tokenizer: repl.Tokenizer): repl.Tokenizer & { calls: number } {
163 return {
164 calls: 0,
165 start: tokenizer.start,
166 next(text, offset, state) {
167 this.calls += 1;
168 return tokenizer.next(text, offset, state);
169 },
170 };
171}
172
173describe("retokenize", () => {
174 test("an edit relexes its surroundings, not the text", () => {
175 const tokenizer = counting(javascript.tokenizer);
176 const line = "const value = { key: [1, 2, 3], other: `a${b}c` };\n";
177 let text = line.repeat(200);
178 const tokens = repl.tokenize(tokenizer, text);
179 const total = tokenizer.calls;
180 ASSERT(total > 5000);
181
182 /** returns how many tokens had to be lexed */
183 const edit = (start: number, removed: number, inserted: string) => {
184 tokenizer.calls = 0;
185 text = text.slice(0, start) + inserted + text.slice(start + removed);
186 repl.retokenize(tokenizer, tokens, text, start, removed, inserted.length);
187 assert.deepEqual(tokens, repl.tokenize(javascript.tokenizer, text));
188 return tokenizer.calls;
189 };
190 // typing at the end, in the middle, and at the start
191 ASSERT(edit(text.length, 0, "x") <= 4);
192 ASSERT(edit(line.length * 100 + 7, 0, "y") <= 4);
193 ASSERT(edit(0, 0, "z") <= 4);
194 ASSERT(edit(line.length * 100 + 7, 1, "") <= 4);
195 ASSERT(edit(0, 1, "") <= 4);
196
197 // an unclosed comment is one token, however much it swallows
198 ASSERT(edit(line.length * 40, 0, "/*") <= 4);
199 // closing it uncovers code which the comment's token knew nothing of
200 ASSERT(edit(line.length * 60, 0, "*/") > total / 2);
201 // then uncommenting only has the commented lines to lex
202 const uncommented = edit(line.length * 40, 2, "");
203 ASSERT(uncommented > total / 20 && uncommented < total / 5);
204 });
205
206 test("any edit gives the same tokens as lexing from scratch", () => {
207 // a generator with a fixed seed, so that failures reproduce
208 let seed = 0x2f6e2b1;
209 const random = (below: number) => {
210 seed = Math.imul(seed ^ (seed >>> 15), seed | 1);
211 seed ^= seed + Math.imul(seed ^ (seed >>> 7), seed | 61);
212 return ((seed ^ (seed >>> 14)) >>> 0) % below;
213 };
214 const pieces = [
215 "/",
216 "*",
217 "`",
218 "${",
219 "}",
220 "{",
221 "(",
222 ")",
223 "[",
224 "]",
225 "'",
226 "\"",
227 "\\",
228 "\n",
229 " ",
230 ".",
231 "?",
232 "=",
233 ">",
234 "+",
235 "x",
236 "1",
237 "e",
238 "E",
239 "-",
240 "n",
241 "_",
242 "#",
243 "$",
244 "0x",
245 "import",
246 "if",
247 "this",
248 "//",
249 "/*",
250 "*/",
251 "é",
252 "😀",
253 ];
254
255 let text = "";
256 const tokens: repl.Token[] = [];
257 for (let i = 0; i < 4000; i += 1) {
258 const start = random(text.length + 1);
259 const removed = random(4) === 0 ? random(Math.min(6, text.length - start) + 1) : 0;
260 let inserted = "";
261 for (let n = random(4); n > 0; n -= 1) inserted += pieces[random(pieces.length)];
262 text = text.slice(0, start) + inserted + text.slice(start + removed);
263
264 repl.retokenize(javascript.tokenizer, tokens, text, start, removed, inserted.length);
265 assert.deepEqual(tokens, repl.tokenize(javascript.tokenizer, text), JSON.stringify(text));
266 // keep the text from growing without bound
267 if (text.length > 120) {
268 text = "";
269 tokens.length = 0;
270 }
271 }
272 });
273});
274
275test("highlight", () => {
276 const { fgMagenta: M, fgGreen: G, fgYellow: Y, reset: R } = ansi;
277 assert.equal(
278 repl.highlight(javascript, "let a = `x\ny${1}`"),
279 `${M}let${R} a = ${G}\`x${R}\n${G}y${R}\${${Y}1${R}}${G}\`${R}`,
280 );
281});
282
283/** press keys by name as a `Repl` would, typing the ones that are not bound */
284function press(editor: repl.Editor, ...keys: string[]) {
285 for (const key of keys) {
286 const binding = repl.keymap[key];
287 ASSERT(typeof binding !== "function", `${key} needs a Repl`);
288 editor.run(() => binding ? editor[binding]() : editor.type(key));
289 }
290}
291
292/** the text, with a bar at the cursor */
293function show(editor: repl.Editor): string {
294 return editor.text.slice(0, editor.cursor) + "|" + editor.text.slice(editor.cursor);
295}
296
297describe("Editor", () => {
298 test("moving by grapheme, word, and line", () => {
299 const editor = new repl.Editor(syntax);
300 press(editor, "foo.bar(baz_1, 'é👩‍👩‍👧')");
301 press(editor, "left", "left", "left");
302 assert.equal(show(editor), "foo.bar(baz_1, 'é|👩‍👩‍👧')");
303 press(editor, "left", "left");
304 assert.equal(show(editor), "foo.bar(baz_1, |'é👩‍👩‍👧')");
305 press(editor, "alt+b", "alt+b");
306 assert.equal(show(editor), "foo.|bar(baz_1, 'é👩‍👩‍👧')");
307 press(editor, "ctrl+right", "alt+f");
308 assert.equal(show(editor), "foo.bar(baz_1|, 'é👩‍👩‍👧')");
309 press(editor, "home", "right", "ctrl+e", "ctrl+b");
310 assert.equal(show(editor), "foo.bar(baz_1, 'é👩‍👩‍👧'|)");
311 press(editor, "ctrl+a", "left", "end", "right");
312 assert.equal(show(editor), "foo.bar(baz_1, 'é👩‍👩‍👧')|");
313 });
314
315 test("moving between lines keeps the column", () => {
316 const editor = new repl.Editor(syntax);
317 editor.replace(0, 0, "a語cdef\nxy\n\n0123456");
318 press(editor, "up", "up", "up", "left");
319 assert.equal(show(editor), "a語cde|f\nxy\n\n0123456");
320 press(editor, "down");
321 assert.equal(show(editor), "a語cdef\nxy|\n\n0123456");
322 press(editor, "down", "down");
323 assert.equal(show(editor), "a語cdef\nxy\n\n012345|6");
324 // the column is counted in cells, and never lands inside a wide one
325 press(editor, "home", "right", "right", "up", "up", "up");
326 assert.equal(show(editor), "a語|cdef\nxy\n\n0123456");
327 press(editor, "left", "left", "right");
328 assert.equal(show(editor), "a|語cdef\nxy\n\n0123456");
329 press(editor, "ctrl+end", "ctrl+home", "left", "delete");
330 assert.equal(show(editor), "|語cdef\nxy\n\n0123456");
331 });
332
333 test("kills collect into one yank", () => {
334 const editor = new repl.Editor(syntax);
335 press(editor, "one two/three four", "ctrl+w", "ctrl+w");
336 assert.equal(show(editor), "one |");
337 press(editor, "ctrl+a", "ctrl+y");
338 assert.equal(show(editor), "two/three four|one ");
339 press(editor, "alt+backspace", "alt+backspace");
340 assert.equal(show(editor), "two/|one ");
341 // a movement in between starts a new kill
342 press(editor, "end", "ctrl+u");
343 assert.equal(show(editor), "|");
344 press(editor, "ctrl+y", "alt+y");
345 assert.equal(show(editor), "three four|");
346 press(editor, "alt+y");
347 assert.equal(show(editor), "two/three four|");
348 // the ring comes back around
349 press(editor, "alt+y");
350 assert.equal(show(editor), "two/one |");
351 press(editor, "home", "alt+d", "alt+d", "ctrl+k", "ctrl+y");
352 assert.equal(show(editor), "two/one |");
353 // without a yank before it, popping does nothing
354 press(editor, "left", "alt+y");
355 assert.equal(show(editor), "two/one| ");
356 });
357
358 test("words do not end inside a grapheme", () => {
359 const editor = new repl.Editor(syntax);
360 press(editor, "a 👩‍👩‍👧 ☀️b", "alt+b");
361 assert.equal(show(editor), "a 👩‍👩‍👧 |☀️b");
362 press(editor, "alt+b", "alt+b", "alt+f", "alt+f");
363 assert.equal(show(editor), "a 👩‍👩‍👧| ☀️b");
364 press(editor, "alt+backspace", "alt+d");
365 assert.equal(show(editor), "a |");
366 });
367
368 test("undo and redo", () => {
369 const editor = new repl.Editor(syntax);
370 press(editor, "a", "b", "c", "left", "x", "y", "backspace", "ctrl+k");
371 assert.equal(show(editor), "abx|");
372 press(editor, "ctrl+_");
373 assert.equal(show(editor), "abx|c");
374 press(editor, "ctrl+_");
375 assert.equal(show(editor), "abxy|c");
376 // typing in a row is undone together
377 press(editor, "ctrl+_");
378 assert.equal(show(editor), "ab|c");
379 press(editor, "ctrl+_", "ctrl+_");
380 assert.equal(show(editor), "|");
381 press(editor, "alt+/", "alt+/");
382 assert.equal(show(editor), "abxy|c");
383 // an edit forgets what was undone
384 press(editor, "z", "alt+/");
385 assert.equal(show(editor), "abxyz|c");
386 assert.deepEqual(editor.tokens, repl.tokenize(javascript.tokenizer, "abxyzc"));
387
388 // redoing puts the cursor where the edit had put it
389 press(editor, "ctrl+u", "ctrl+k", " ", " ", " ", " ", "}");
390 assert.equal(show(editor), " }|");
391 press(editor, "ctrl+_", "alt+/");
392 assert.equal(show(editor), " }|");
393 // typing nothing is not an edit
394 editor.run(() => editor.type(""));
395 press(editor, "ctrl+_", "ctrl+_");
396 assert.equal(show(editor), "|");
397 });
398
399 test("new lines are indented, and closing brackets are not", () => {
400 const editor = new repl.Editor(syntax);
401 press(editor, "if (x) {", "shift+enter", "f([", "ctrl+j", "1", "alt+enter", "]", ")", "ctrl+j", "}");
402 assert.equal(editor.text, "if (x) {\n f([\n 1\n ])\n}");
403 // tab indents a blank line
404 press(editor, "ctrl+j", "tab", "tab", "x");
405 assert.equal(editor.text, "if (x) {\n f([\n 1\n ])\n}\n x");
406 });
407
408 test("history", () => {
409 const editor = new repl.Editor(syntax, ["first", "f(\n 2\n)"]);
410 press(editor, "third");
411 assert.equal(editor.accept(), "third");
412 // blank lines and repeats are not remembered
413 press(editor, "third");
414 editor.accept();
415 press(editor, " ");
416 editor.accept();
417 assert.deepEqual(editor.history, ["first", "f(\n 2\n)", "third"]);
418
419 press(editor, "draft", "up", "up");
420 assert.equal(show(editor), "f(\n 2\n)|");
421 // inside a recalled entry, up moves through its lines first
422 press(editor, "up", "up");
423 assert.equal(show(editor), "f|(\n 2\n)");
424 press(editor, "up");
425 assert.equal(show(editor), "first|");
426 press(editor, "up", "down", "down", "ctrl+n");
427 assert.equal(show(editor), "draft|");
428 press(editor, "down");
429 assert.equal(show(editor), "draft|");
430 // recalling is an edit like any other
431 press(editor, "ctrl+p", "ctrl+_");
432 assert.equal(show(editor), "draft|");
433 // and an edit is a new line being written, which is not lost
434 press(editor, "!", "up");
435 assert.equal(show(editor), "third|");
436 press(editor, "x", "up");
437 assert.equal(show(editor), "third|");
438 press(editor, "down");
439 assert.equal(show(editor), "thirdx|");
440 });
441
442 test("history by prefix", () => {
443 const editor = new repl.Editor(syntax, ["db.a", "x", "db.b", "y"]);
444 press(editor, "db.", "pageup");
445 assert.equal(show(editor), "db.|b");
446 press(editor, "pageup", "pageup");
447 assert.equal(show(editor), "db.|a");
448 press(editor, "pagedown", "pagedown");
449 assert.equal(show(editor), "db.|");
450 // the cursor stays within what is shown
451 press(editor, "pageup", "end", "pagedown");
452 assert.equal(show(editor), "db.|");
453 press(editor, "ctrl+u", "pageup", "end", "pagedown");
454 assert.equal(show(editor), "|");
455 });
456
457 test("history search", () => {
458 const editor = new repl.Editor(syntax, ["alpha beta", "gamma", "beta max", "delta"]);
459 press(editor, "keep", "ctrl+r");
460 assert.deepEqual(editor.search, { query: "", failed: false });
461 press(editor, "b", "e");
462 assert.equal(show(editor), "be|ta max");
463 press(editor, "ctrl+r");
464 assert.equal(show(editor), "alpha be|ta");
465 press(editor, "ctrl+r");
466 assert.deepEqual(editor.search, { query: "be", failed: true });
467 press(editor, "ctrl+s");
468 assert.equal(show(editor), "be|ta max");
469 // a longer query keeps the entry on display if it still matches
470 press(editor, "ctrl+r", "t", "a");
471 assert.equal(show(editor), "alpha beta|");
472 press(editor, " ", "m");
473 assert.deepEqual(editor.search, { query: "beta m", failed: true });
474 // a shorter one looks again from the newest entry
475 press(editor, "backspace");
476 assert.equal(show(editor), "beta |max");
477 press(editor, "backspace", "backspace", "backspace", "backspace", "backspace", "backspace");
478 assert.equal(show(editor), "keep|");
479 assert.deepEqual(editor.search, { query: "", failed: false });
480
481 // another key leaves the search with what it found
482 press(editor, "g", "a", "left");
483 assert.equal(show(editor), "g|amma");
484 assert.equal(editor.search, null);
485 // and escape leaves it with what was there before
486 press(editor, "ctrl+r", "d", "e", "l");
487 assert.equal(show(editor), "del|ta");
488 press(editor, "escape");
489 assert.equal(show(editor), "gamma|");
490 assert.equal(editor.search, null);
491 // a search is undone in as many steps as it changed the text
492 press(editor, "ctrl+r", "a", "l", "p", "h", "a", "left", "ctrl+_");
493 assert.equal(show(editor), "delta|");
494 press(editor, "ctrl+_");
495 assert.equal(show(editor), "gamma|");
496 });
497
498 test("completions are narrowed down loosely, the closest first", () => {
499 const words = ["alphaBeta", "theBest", "beta", "Bet", "formatMenu", "format", "forEach", "wait-up"];
500 const editor = new repl.Editor({
501 tokenizer: javascript.tokenizer,
502 complete: () => ({ from: 0, options: words }),
503 });
504 const narrowed = (typed: string) => {
505 press(editor, "ctrl+u", typed);
506 return editor.menu?.options ?? [];
507 };
508 // what the text begins, then where it begins a word
509 assert.deepEqual(narrowed("b"), ["beta", "Bet", "theBest", "alphaBeta"]);
510 assert.deepEqual(narrowed("bet"), ["beta", "Bet", "alphaBeta"]);
511 assert.deepEqual(narrowed("bes"), ["theBest"]);
512 // or the starts of several words
513 assert.deepEqual(narrowed("fm"), ["formatMenu"]);
514 assert.deepEqual(narrowed("fe"), ["forEach"]);
515 assert.deepEqual(narrowed("wu"), ["wait-up"]);
516 assert.deepEqual(narrowed("form"), ["formatMenu", "format"]);
517 // or anywhere, as long as it is in one piece
518 assert.deepEqual(narrowed("ach"), ["forEach"]);
519 assert.deepEqual(narrowed("eta"), ["beta", "alphaBeta"]);
520 assert.deepEqual(narrowed("bx"), []);
521 });
522
523 test("completion", () => {
524 const words = ["forEach", "format", "formatAnsi", "formatMenu"];
525 const editor = new repl.Editor({
526 tokenizer: javascript.tokenizer,
527 // members come up on their own, and names in scope only when asked for
528 complete(code, cursor, asked) {
529 const from = code.lastIndexOf(".", cursor - 1) + 1;
530 if (from === 0 && !asked) return null;
531 return { from, options: words.filter((word) => word.startsWith(code.slice(from, cursor))) };
532 },
533 });
534 press(editor, "x");
535 assert.equal(editor.menu, null);
536 press(editor, ".");
537 assert.deepEqual(editor.menu, { from: 2, options: words, selected: 0 });
538 // typing narrows the options down, and the arrows move among them
539 press(editor, "f", "o", "r", "m", "down", "down", "down", "up");
540 assert.deepEqual(editor.menu, { from: 2, options: words.slice(1), selected: 2 });
541 assert.equal(show(editor), "x.form|");
542 press(editor, "tab");
543 assert.equal(show(editor), "x.formatMenu|");
544 assert.equal(editor.menu, null);
545
546 // erasing brings them back, unless all there is to offer is typed out
547 press(editor, "backspace");
548 assert.deepEqual(editor.menu, { from: 2, options: ["formatMenu"], selected: 0 });
549 press(editor, "u");
550 assert.equal(editor.menu, null);
551 // any other key puts the menu away
552 press(editor, "backspace", "left");
553 assert.equal(editor.menu, null);
554 press(editor, "end", "backspace", "escape", "(");
555 assert.equal(show(editor), "x.formatMe(|");
556 assert.equal(editor.menu, null);
557
558 // when asked for, there is more on offer, also as it is narrowed down
559 press(editor, "ctrl+u", "f");
560 assert.equal(editor.menu, null);
561 press(editor, "tab", "o", "r");
562 assert.deepEqual(editor.menu, { from: 0, options: words, selected: 0 });
563 press(editor, "E", "tab");
564 assert.equal(show(editor), "forEach|");
565 // the only completion there is gets taken without a menu
566 press(editor, "ctrl+u", "formatA", "tab");
567 assert.equal(show(editor), "formatAnsi|");
568 assert.equal(editor.menu, null);
569 press(editor, "ctrl+u", "q", "tab");
570 assert.equal(show(editor), "q|");
571 assert.equal(editor.menu, null);
572 });
573});
574
575describe("Editor with a language that answers later", () => {
576 /** an editor, and what answers the completions that it last asked for */
577 function waiting() {
578 const state = { answer: (_: repl.Completion | null) => {}, asked: [] as string[] };
579 const editor = new repl.Editor({
580 tokenizer: javascript.tokenizer,
581 complete(code) {
582 state.asked.push(code);
583 return new Promise<repl.Completion | null>((resolve) => state.answer = resolve);
584 },
585 });
586 return { editor, state };
587 }
588 const options = ["alpha", "alpine", "beta"];
589 /** what is on the menu, read anew so that one assertion does not narrow the next */
590 const offered = (editor: repl.Editor) => editor.menu?.options ?? null;
591
592 test("completions come up once they are there", async () => {
593 const { editor, state } = waiting();
594 const late = editor.run(() => editor.type("a"));
595 assert.equal(offered(editor), null);
596 state.answer({ from: 0, options });
597 await late;
598 assert.deepEqual(offered(editor), options);
599
600 // until the next answer, what is on offer is narrowed down as it is
601 const narrowed = editor.run(() => editor.type("lpi"));
602 assert.deepEqual(offered(editor), ["alpine"]);
603 state.answer({ from: 0, options: ["alpine", "alpinist"] });
604 await narrowed;
605 assert.deepEqual(offered(editor), ["alpine", "alpinist"]);
606 assert.deepEqual(state.asked, ["a", "alpi"]);
607 });
608
609 test("an answer that a key was pressed after is dropped", async () => {
610 const { editor, state } = waiting();
611 const first = editor.run(() => editor.type("a"));
612 const answerFirst = state.answer;
613 const second = editor.run(() => editor.type("l"));
614 answerFirst({ from: 0, options: ["wrong"] });
615 await first;
616 assert.equal(offered(editor), null);
617 state.answer({ from: 0, options });
618 await second;
619 assert.deepEqual(offered(editor), ["alpha", "alpine"]);
620
621 // so is one that the menu was closed before
622 const third = editor.run(() => editor.type("p"));
623 const answerThird = state.answer;
624 press(editor, "escape");
625 answerThird({ from: 0, options });
626 await third;
627 assert.equal(offered(editor), null);
628 });
629
630 test("tab takes the only completion when it turns out to be one", async () => {
631 const { editor, state } = waiting();
632 editor.replace(0, 0, "be");
633 const late = editor.run(() => editor.complete());
634 assert.equal(editor.text, "be");
635 state.answer({ from: 0, options });
636 await late;
637 assert.equal(editor.text, "beta");
638 assert.equal(offered(editor), null);
639
640 // a language that fails to answer offers nothing
641 const { editor: other } = waiting();
642 const failing = new repl.Editor({
643 tokenizer: javascript.tokenizer,
644 complete: () => Promise.reject(new Error("no")),
645 });
646 await failing.run(() => failing.type("a"));
647 assert.equal(offered(failing), null);
648 assert.equal(offered(other), null);
649 });
650});
651
652describe("formatAnsi", () => {
653 const view = { prompt: "> ", styles: {}, width: 7, height: 10 };
654 const more = (mark: string) => ansi.style(ansi.dim, mark) + " ";
655 const edit = (text: string, cursor = text.length) => {
656 const editor = new repl.Editor(syntax);
657 editor.replace(0, 0, text);
658 editor.cursor = cursor;
659 return editor;
660 };
661
662 test("long lines wrap under the prompt", () => {
663 assert.deepEqual(repl.formatAnsi(edit("abc"), view), {
664 text: "> abc",
665 cursor: { line: 0, column: 5 },
666 scroll: 0,
667 });
668 assert.deepEqual(repl.formatAnsi(edit("abcdefgh"), view), {
669 text: "> abcde\n fgh",
670 cursor: { line: 1, column: 5 },
671 scroll: 0,
672 });
673 // at a wrap, the cursor belongs to the row below
674 assert.deepEqual(repl.formatAnsi(edit("abcdefgh", 5), view).cursor, { line: 1, column: 2 });
675 // but a full last row has nowhere below
676 assert.deepEqual(repl.formatAnsi(edit("abcde"), view).cursor, { line: 0, column: 7 });
677 });
678
679 test("lines after the first are marked", () => {
680 assert.deepEqual(repl.formatAnsi(edit("ab\n\ncd", 3), view), {
681 text: `> ab\n${more("…")}\n${more("…")}cd`,
682 cursor: { line: 1, column: 2 },
683 scroll: 0,
684 });
685 });
686
687 test("wide and control characters", () => {
688 const frame = repl.formatAnsi(edit("語語語x\ta\x00\x7f", 7), view);
689 // a wide character wraps whole, and a tab is as wide as an indent
690 assert.equal(frame.text, "> 語語\n 語x \n a\u{2400}\u{2421}");
691 assert.deepEqual(frame.cursor, { line: 2, column: 4 });
692 });
693
694 test("tall text scrolls to the cursor", () => {
695 const editor = edit("0\n1\n2\n3\n4");
696 const short = { ...view, height: 3 };
697 let frame = repl.formatAnsi(editor, short);
698 assert.equal(frame.text, `${more("↑")}2\n${more("…")}3\n${more("…")}4`);
699 assert.deepEqual(frame.cursor, { line: 2, column: 3 });
700 assert.equal(frame.scroll, 2);
701
702 // moving within view does not scroll
703 editor.cursor = 4;
704 frame = repl.formatAnsi(editor, { ...short, scroll: frame.scroll });
705 assert.deepEqual([frame.cursor.line, frame.scroll], [0, 2]);
706 editor.cursor = 2;
707 frame = repl.formatAnsi(editor, { ...short, scroll: frame.scroll });
708 assert.equal(frame.text, `${more("↑")}1\n${more("…")}2\n${more("↓")}3`);
709 assert.deepEqual([frame.cursor.line, frame.scroll], [0, 1]);
710 editor.cursor = 0;
711 frame = repl.formatAnsi(editor, { ...short, scroll: frame.scroll });
712 assert.equal(frame.text, `> 0\n${more("…")}1\n${more("↓")}2`);
713 });
714
715 test("tokens are styled across rows", () => {
716 const frame = repl.formatAnsi(edit("x = 'abcdef'"), { ...view, styles: javascript.styles });
717 const { fgGreen: G, reset: R } = ansi;
718 assert.equal(frame.text, `> x = ${G}'${R}\n ${G}abcde${R}\n ${G}f'${R}`);
719 });
720
721 // how the box of a menu is drawn
722 const plain = ansi.bgBrightBlack + ansi.fgBrightWhite;
723 const chosen = ansi.bgBlue + ansi.fgBrightWhite;
724 const hit = (text: string) =>
725 Array.from(text, (char) => ansi.bold + ansi.fgBrightYellow + char + ansi.resetWeight + ansi.fgBrightWhite).join("");
726
727 test("the menu is a box under what it completes", () => {
728 const wide = { ...view, width: 16 };
729 const editor = edit("x.ab");
730 editor.menu = { from: 2, options: ["abc", "a_bx", "abcdef"], selected: 1 };
731 const frame = repl.formatAnsi(editor, wide);
732 // what was typed is picked out of each option, wherever it matched
733 assert.equal(
734 frame.text,
735 [
736 "> x.ab",
737 ` ${plain} ${hit("ab")}c ${ansi.reset}`,
738 ` ${chosen} ${hit("a")}_${hit("b")}x ${ansi.reset}`,
739 ` ${plain} ${hit("ab")}cdef ${ansi.reset}`,
740 ].join("\n"),
741 );
742 assert.deepEqual(frame.cursor, { line: 0, column: 6 });
743
744 // it moves over to fit, and the search goes under everything
745 editor.search = { query: "q", failed: true };
746 const searching = `${ansi.style(ansi.dim, "search:")} q ${ansi.style(ansi.fgRed, "no match")}`;
747 assert.deepEqual(repl.formatAnsi(editor, { ...wide, width: 9 }).text.split("\n").slice(3), [
748 ` ${plain} ${hit("ab")}cdef ${ansi.reset}`,
749 searching,
750 ]);
751 });
752
753 test("a long menu is shown a page at a time", () => {
754 const editor = edit("a");
755 const options = Array.from({ length: 10 }, (_, i) => `a${i}`);
756 const row = (option: string, style = plain) => ` ${style} ${hit("a")}${option.slice(1)} ${ansi.reset}`;
757 editor.menu = { from: 0, options, selected: 0 };
758 let frame = repl.formatAnsi(editor, view);
759 assert.deepEqual(frame.text.split("\n"), ["> a", row("a0", chosen), ...options.slice(1, 6).map((o) => row(o))]);
760 editor.menu.selected = 9;
761 frame = repl.formatAnsi(editor, view);
762 assert.deepEqual(frame.text.split("\n"), ["> a", row("a6"), row("a7"), row("a8"), row("a9", chosen)]);
763 // the text keeps its row, however little room there is
764 frame = repl.formatAnsi(editor, { ...view, height: 2 });
765 assert.deepEqual(frame.text.split("\n"), ["> a", row("a9", chosen)]);
766 assert.equal(repl.formatAnsi(editor, { ...view, height: 1 }).text, "> a");
767 });
768
769 test("lines are drawn above and below, which the menu hangs through", () => {
770 const line = (width: number) => ansi.style(ansi.dim, "─".repeat(width));
771 const editor = edit("ab\nc");
772 let frame = repl.formatAnsi(editor, { ...view, style: "lines" });
773 assert.equal(frame.text, `${line(7)}\n> ab\n${more("…")}c\n${line(7)}`);
774 assert.deepEqual(frame.cursor, { line: 2, column: 3 });
775
776 editor.menu = { from: 3, options: ["cd", "ce"], selected: 0 };
777 frame = repl.formatAnsi(editor, { ...view, width: 12, style: "lines" });
778 assert.deepEqual(frame.text.split("\n").slice(3), [
779 `${line(1)}${chosen} ${hit("c")}d ${ansi.reset}${line(7)}`,
780 ` ${plain} ${hit("c")}e ${ansi.reset}`,
781 ]);
782 // with next to no room, the lines stay and the menu has their row
783 frame = repl.formatAnsi(editor, { ...view, width: 12, height: 3, style: "lines" });
784 assert.deepEqual(frame.text.split("\n"), [
785 line(12),
786 `${more("↑")}c`,
787 `${line(1)}${chosen} ${hit("c")}d ${ansi.reset}${line(7)}`,
788 ]);
789 });
790
791 test("a title is set into the upper line, as far as it fits", () => {
792 const top = (text: string, width: number, title: string) =>
793 ansi.strip(repl.formatAnsi(edit(text), { ...view, width, style: "lines", title }).text).split("\n")[0];
794 assert.equal(top("", 16, "node"), "─┤ node ├───────");
795 assert.equal(top("", 11, "node"), "─┤ node ├──");
796 assert.equal(top("", 10, "node"), "─┤ node ├─");
797 assert.equal(top("", 9, "node"), "─┤ nod ├─");
798 assert.equal(top("", 6, "node"), "──────");
799 // a style without edges has nowhere to put one
800 assert.equal(repl.formatAnsi(edit("a"), { ...view, title: "node" }).text, "> a");
801 });
802
803 test("a filled prompt has a background where the lines would be", () => {
804 const grey = ansi.bg256(237) + ansi.fgBrightWhite;
805 const fill = (text: string) => grey + text + ansi.reset;
806 const editor = edit("ab");
807 let frame = repl.formatAnsi(editor, { ...view, style: "filled", title: "sh" });
808 assert.deepEqual(frame.text.split("\n"), [
809 fill(` ${ansi.style(ansi.bold, "sh")} `),
810 fill("> ab "),
811 fill(" "),
812 ]);
813 assert.deepEqual(frame.cursor, { line: 1, column: 4 });
814
815 // what resets its own style does not take the background with it
816 const styled = repl.formatAnsi(edit("1"), { ...view, style: "filled", styles: { number: ansi.fgYellow } });
817 assert.equal(
818 styled.text.split("\n")[1],
819 `${grey}> ${ansi.fgYellow}1${ansi.reset}${grey} ${ansi.reset}`,
820 );
821
822 editor.menu = { from: 0, options: ["abc"], selected: 0 };
823 frame = repl.formatAnsi(editor, { ...view, width: 9, style: "filled" });
824 assert.equal(
825 frame.text.split("\n")[2],
826 `${grey} ${chosen} ${hit("ab")}c ${ansi.reset}${grey} ${ansi.reset}`,
827 );
828 });
829
830 test("rows are kept under the prompt for what is not there yet", () => {
831 const editor = edit("ab");
832 assert.equal(repl.formatAnsi(editor, { ...view, reserve: 2 }).text, "> ab\n\n");
833 // under the lower edge, where there is one
834 const line = ansi.style(ansi.dim, "─".repeat(7));
835 assert.equal(repl.formatAnsi(editor, { ...view, style: "lines", reserve: 1 }).text, `${line}\n> ab\n${line}\n`);
836 // a menu is in them, and the text is not given up for them
837 editor.menu = { from: 0, options: ["abc", "abd"], selected: 0 };
838 assert.equal(repl.formatAnsi(editor, { ...view, reserve: 2 }).text.split("\n").length, 3);
839 assert.equal(repl.formatAnsi(edit("ab"), { ...view, height: 2, reserve: 2 }).text, "> ab\n");
840 });
841
842 test("a placeholder stands in for text that is not there", () => {
843 const placeholder = "ask anything";
844 const dim = (text: string) => ansi.style(ansi.dim, text);
845 let frame = repl.formatAnsi(edit(""), { ...view, width: 20, placeholder });
846 assert.equal(frame.text, `> ${dim(placeholder)}`);
847 assert.deepEqual(frame.cursor, { line: 0, column: 2 });
848 frame = repl.formatAnsi(edit(""), { ...view, placeholder });
849 assert.equal(frame.text, `> ${dim("ask a")}`);
850 assert.equal(repl.formatAnsi(edit("a"), { ...view, width: 20, placeholder }).text, "> a");
851 });
852});
853
854/** a language where evaluation waits to be told its result */
855function manualLanguage() {
856 const evaluating: { code: string; resolve(value: unknown): void; reject(error: unknown): void }[] = [];
857 const language: repl.Language = {
858 tokenizer: javascript.tokenizer,
859 styles: javascript.styles,
860 incomplete: (_, state) => state.length > 1,
861 eval: (code, print) =>
862 new Promise<unknown>((resolve, reject) => {
863 evaluating.push({ code, resolve, reject });
864 }).then((value) => {
865 if (value !== undefined) print(value);
866 }),
867 // only when asked, apart from the words after a colon
868 complete(code, cursor, asked) {
869 const from = code.lastIndexOf(":", cursor - 1) + 1;
870 const words = ["alpha", "alpine", "beta"].filter((word) => word.startsWith(code.slice(from, cursor)));
871 return asked || from > 0 ? { from, options: words } : null;
872 },
873 };
874 return { language, evaluating };
875}
876
877/** the lines on the terminal, scrollback included */
878function linesOf(screen: testing.MockScreen): string[] {
879 return screen.terminal.text.split("\n");
880}
881
882/** let promise reactions run, then draw */
883async function settle(screen: testing.MockScreen, ms = 0) {
884 await new Promise((resolve) => setImmediate(resolve));
885 screen.advance(ms);
886}
887
888describe("Repl", () => {
889 test("a line is echoed, evaluated, and answered", async () => {
890 const screen = new testing.MockScreen();
891 const { language, evaluating } = manualLanguage();
892 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
893 screen.advance();
894 assert.equal(screen.terminal.text, ">");
895 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 2 });
896
897 screen.type("1 + 1");
898 screen.advance();
899 assert.equal(screen.terminal.text, "> 1 + 1");
900 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 7 });
901
902 // the prompt is away while the line is evaluated
903 screen.type("\rab");
904 screen.advance();
905 assert.equal(screen.terminal.text, "> 1 + 1");
906 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 0 });
907 assert.deepEqual(evaluating.map((entry) => entry.code), ["1 + 1"]);
908
909 // and what was typed meanwhile is not lost
910 evaluating[0]?.resolve(2);
911 await settle(screen);
912 assert.equal(screen.terminal.text, "> 1 + 1\n2\n> ab");
913 assert.deepEqual(screen.terminal.cursor, { line: 2, column: 4 });
914 assert.deepEqual(prompt.editor.history, ["1 + 1"]);
915
916 screen.type("\x7f\x7fundefined\r");
917 evaluating[1]?.resolve(undefined);
918 await settle(screen);
919 screen.type("x\r");
920 evaluating[2]?.reject(new RangeError("out of it"));
921 await settle(screen);
922 screen.type("y\r");
923 evaluating[3]?.reject("a string");
924 await settle(screen);
925 const lines = linesOf(screen);
926 assert.deepEqual(lines.slice(2, 5), ["> undefined", "> x", "RangeError: out of it"]);
927 assert.deepEqual(lines.slice(-3), ["> y", "uncaught 'a string'", ">"]);
928
929 prompt.close();
930 screen.advance();
931 ASSERT(!screen.reading);
932 assert.deepEqual(linesOf(screen).slice(-2), ["> y", "uncaught 'a string'"]);
933 screen.cancel();
934 });
935
936 test("a failure that cannot be formatted still gives the prompt back", async () => {
937 const screen = new testing.MockScreen();
938 const { language, evaluating } = manualLanguage();
939 using prompt = new repl.Repl({
940 interface: screen,
941 language: { ...language, complete: (_code, _cursor, asked) => asked ? UNWRAP(null) : null },
942 prompt: "> ",
943 });
944 screen.advance();
945 screen.type("x\rab");
946 const odd = Object.defineProperty(new Error("odd"), "field", {
947 enumerable: true,
948 get: () => {
949 throw new Error("unreadable");
950 },
951 });
952 evaluating[0]?.reject(odd);
953 await settle(screen);
954 assert.deepEqual(linesOf(screen), ["> x", "odd", "> ab"]);
955 // as does a key binding that throws
956 screen.type("\tc");
957 screen.advance();
958 assert.equal(linesOf(screen).at(-1), "> abc");
959 ASSERT(linesOf(screen).includes("error: Unexpected null"));
960
961 // closing forgets what was typed ahead
962 screen.type("\rd\r");
963 prompt.close();
964 evaluating[1]?.resolve(undefined);
965 await settle(screen);
966 assert.deepEqual(evaluating.map((entry) => entry.code), ["x", "abc"]);
967 screen.cancel();
968 });
969
970 test("an unfinished line continues", () => {
971 const screen = new testing.MockScreen();
972 const { language, evaluating } = manualLanguage();
973 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
974 screen.advance();
975 screen.type("f({\r");
976 screen.advance();
977 assert.equal(screen.terminal.text, "> f({\n…");
978 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 });
979 screen.type("a\r})\r");
980 screen.advance();
981 assert.equal(evaluating[0]?.code, "f({\n a\n})");
982 assert.equal(screen.terminal.text, "> f({\n… a\n… })");
983 // a blank line is not evaluated at all
984 prompt.close();
985 screen.advance();
986 screen.cancel();
987 });
988
989 test("pasted text is not entered line by line", () => {
990 const screen = new testing.MockScreen();
991 const { language, evaluating } = manualLanguage();
992 using _ = new repl.Repl({ interface: screen, language, prompt: "> " });
993 screen.advance();
994 screen.type("\x1b[200~a\r\n\tb\x1b[201~");
995 screen.advance();
996 assert.equal(screen.terminal.text, "> a\n… b");
997 assert.equal(evaluating.length, 0);
998 screen.type("\x03");
999 screen.advance();
1000 assert.equal(screen.terminal.text, ">");
1001 screen.cancel();
1002 });
1003
1004 test("commands run beside the prompt", async () => {
1005 const screen = new testing.MockScreen();
1006 const calls: string[] = [];
1007 let finish = () => {};
1008 using prompt = new repl.Repl({
1009 interface: screen,
1010 language: null,
1011 prompt: "> ",
1012 commands: {
1013 greet: {
1014 async execute(args, node) {
1015 ASSERT(this === prompt);
1016 calls.push(args);
1017 node.log.info("hello", args);
1018 using _ = node.start("counting", { total: 4, value: 1 });
1019 await new Promise<void>((resolve) => finish = resolve);
1020 },
1021 },
1022 "wait-up": {
1023 block: true,
1024 execute: () => new Promise<void>((resolve) => finish = resolve),
1025 },
1026 // names that cannot be typed as a command are never run or offered
1027 "": { execute: () => void calls.push("nameless") },
1028 "gr eet": { execute: () => void calls.push("spaced") },
1029 fail: {
1030 execute() {
1031 throw new Error("no");
1032 },
1033 },
1034 },
1035 });
1036 screen.advance();
1037
1038 // the names of commands come up at the slash, lined up under it
1039 screen.type("/");
1040 screen.advance();
1041 assert.equal(screen.terminal.text, "> /\n fail\n greet\n wait-up");
1042 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 });
1043 // the first is highlighted, which enter takes before it enters the line
1044 screen.type("g\r");
1045 screen.advance();
1046 assert.equal(linesOf(screen).at(-1), "> /greet");
1047 assert.deepEqual(calls, []);
1048 screen.type(" to all \r");
1049 await settle(screen, 1);
1050 assert.deepEqual(calls, ["to all"]);
1051 const bar = "███ ";
1052 assert.equal(
1053 screen.terminal.text,
1054 ["> /greet to all", ">", "⠋ /greet to all", `└─ ${bar} [1/4] counting`].join("\n"),
1055 );
1056 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1057
1058 // the prompt keeps working above the progress display
1059 screen.type("hi");
1060 screen.advance();
1061 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 });
1062 finish();
1063 await settle(screen, 1);
1064 // what the command logged to its node comes out once it is done
1065 const logged = ["> /greet to all", "[logs from /greet to all]", "info: hello to all"];
1066 assert.deepEqual(linesOf(screen), [...logged, "> hi"]);
1067
1068 screen.type("\r");
1069 await settle(screen);
1070 assert.deepEqual(linesOf(screen), [...logged, "> hi", "not a command", ">"]);
1071
1072 // unless the command asked for the prompt to wait
1073 screen.type("/wait-up\r/fail\r");
1074 await settle(screen, 1);
1075 assert.deepEqual(linesOf(screen).slice(-2), ["> /wait-up", "⠋ /wait-up"]);
1076 finish();
1077 await settle(screen, 1);
1078 await settle(screen, 1);
1079 // a failure is reported with its trace
1080 const lines = linesOf(screen);
1081 const failed = lines.lastIndexOf("> /fail");
1082 assert.deepEqual(lines.slice(failed - 1, failed + 2), ["> /wait-up", "> /fail", "error: no"]);
1083 assert.equal(lines.at(-1), ">");
1084
1085 prompt.close();
1086 screen.advance(1);
1087 screen.cancel();
1088 });
1089
1090 test("completions are listed under the prompt", async () => {
1091 const screen = new testing.MockScreen();
1092 const { language, evaluating } = manualLanguage();
1093 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
1094 screen.advance();
1095 // asked for with tab
1096 screen.type("a\t");
1097 screen.advance();
1098 assert.equal(screen.terminal.text, "> a\n alpha\n alpine");
1099 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 });
1100 // the arrows choose, enter takes the choice, and enter again the line
1101 screen.type("\x1b[B\r");
1102 screen.advance();
1103 assert.equal(screen.terminal.text, "> alpine");
1104 assert.equal(evaluating.length, 0);
1105 screen.type("\r");
1106 evaluating[0]?.resolve(undefined);
1107 await settle(screen);
1108 assert.equal(evaluating[0]?.code, "alpine");
1109
1110 // or coming up as the language sees fit, here after a colon
1111 screen.type("x:");
1112 screen.advance();
1113 assert.deepEqual(linesOf(screen).slice(-4), ["> x:", " alpha", " alpine", " beta"]);
1114 // an option that is typed out leaves enter to do its usual
1115 screen.type("beta\r");
1116 screen.advance();
1117 assert.equal(evaluating[1]?.code, "x:beta");
1118 evaluating[1]?.resolve(undefined);
1119 await settle(screen);
1120
1121 // a history search is shown there as well
1122 screen.type("\x12al");
1123 screen.advance();
1124 assert.deepEqual(linesOf(screen).slice(-2), ["> alpine", "search: al"]);
1125 assert.deepEqual(screen.terminal.cursor, { line: linesOf(screen).length - 2, column: 4 });
1126 prompt.close();
1127 screen.advance();
1128 screen.cancel();
1129 });
1130
1131 test("a command completes what follows its name", async () => {
1132 const screen = new testing.MockScreen();
1133 const ran: string[] = [];
1134 const word = (args: string) => args.lastIndexOf(" ") + 1;
1135 using prompt = new repl.Repl({
1136 interface: screen,
1137 language: manualLanguage().language,
1138 prompt: "> ",
1139 commands: {
1140 deploy: {
1141 execute: (args) => void ran.push(args),
1142 // the first word is offered unasked, and the ones after it by tab
1143 complete: (args, asked) =>
1144 asked || !args.includes(" ") ? { from: word(args), options: ["production", "preview", "staging"] } : null,
1145 },
1146 // a function is a command as well, without anything to complete
1147 plain: (args) => void ran.push("plain " + args),
1148 },
1149 });
1150 screen.advance();
1151
1152 // options are lined up under what they would stand in for
1153 const under = (column: number, option: string) => " ".repeat(column) + option;
1154 screen.type("/deploy ");
1155 screen.advance();
1156 assert.deepEqual(linesOf(screen), [
1157 "> /deploy",
1158 under(10, "production"),
1159 under(10, "preview"),
1160 under(10, "staging"),
1161 ]);
1162 // narrowed by what is typed, loosely, and taken by enter
1163 screen.type("ag");
1164 screen.advance();
1165 assert.deepEqual(linesOf(screen), ["> /deploy ag", under(10, "staging")]);
1166 screen.type("\r x");
1167 screen.advance();
1168 assert.deepEqual(linesOf(screen), ["> /deploy staging x"]);
1169 screen.type("\x7fp\t");
1170 screen.advance();
1171 assert.deepEqual(linesOf(screen), ["> /deploy staging p", under(18, "production"), under(18, "preview")]);
1172 screen.type("\x1b[B\r\r");
1173 await settle(screen, 1);
1174 assert.deepEqual(ran, ["staging preview"]);
1175
1176 // a command that has nothing to say gets no offers, the language's included
1177 screen.type("/plain a\t");
1178 screen.advance();
1179 assert.equal(linesOf(screen).at(-1), "> /plain a");
1180 assert.equal(prompt.editor.menu, null);
1181 screen.type("\r");
1182 await settle(screen, 1);
1183 assert.deepEqual(ran, ["staging preview", "plain a"]);
1184 prompt.close();
1185 screen.advance();
1186 screen.cancel();
1187 });
1188
1189 test("a row is kept under the prompt, which work is shown in", async () => {
1190 const drawn = (text: string, up: number) =>
1191 testing.MockScreen.sync([`\x1b[?7l${text}\x1b[?7h`, ansi.cursorUp(up) + ansi.cursorRight(2)]);
1192 const asking = "\x1b[?2004h\x1b[?u\x1b[>c";
1193
1194 const screen = new testing.MockScreen();
1195 let finish = () => {};
1196 const commands = { wait: { execute: () => new Promise<void>((resolve) => finish = resolve) } };
1197 using prompt = new repl.Repl({ interface: screen, language: null, prompt: "> ", commands });
1198 screen.expectFrame(0, { stderr: asking + drawn("> \n\n", 2) });
1199 screen.type("/wait\r");
1200 await settle(screen, 1);
1201 assert.equal(screen.terminal.text, "> /wait\n>\n⠋ /wait");
1202 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1203 finish();
1204 await settle(screen, 1);
1205 prompt.close();
1206 screen.advance();
1207 screen.cancel();
1208
1209 const bare = new testing.MockScreen();
1210 using none = new repl.Repl({ interface: bare, language: null, prompt: "> ", reserve: 0 });
1211 bare.expectFrame(0, { stderr: asking + drawn("> \n", 1) });
1212 none.close();
1213 bare.advance();
1214 bare.cancel();
1215 });
1216
1217 test("the prompt is styled, titled, and filled in as asked for", () => {
1218 const screen = new testing.MockScreen();
1219 screen.resize(16);
1220 const { language } = manualLanguage();
1221 using prompt = new repl.Repl({
1222 interface: screen,
1223 language,
1224 prompt: "> ",
1225 style: "lines",
1226 title: "node",
1227 placeholder: "ask away",
1228 });
1229 screen.advance();
1230 const line = "─".repeat(15);
1231 assert.equal(screen.terminal.text, ["─┤ node ├──────", "> ask away", line].join("\n"));
1232 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1233 screen.type("f(\r");
1234 screen.advance();
1235 assert.equal(screen.terminal.text, ["─┤ node ├──────", "> f(", "…", line].join("\n"));
1236 assert.deepEqual(screen.terminal.cursor, { line: 2, column: 4 });
1237 prompt.close();
1238 screen.advance();
1239 screen.cancel();
1240 });
1241
1242 test("keys can be rebound", () => {
1243 const screen = new testing.MockScreen();
1244 const { language } = manualLanguage();
1245 const seen: string[] = [];
1246 using prompt = new repl.Repl({
1247 interface: screen,
1248 language,
1249 prompt: "> ",
1250 keymap: {
1251 "ctrl+a": null,
1252 "ctrl+e": "moveLineStart",
1253 "f1": (editor, self) => {
1254 ASSERT(self === prompt);
1255 seen.push(editor.text);
1256 },
1257 },
1258 });
1259 screen.advance();
1260 screen.type("abc\x01\x05x\x1bOP");
1261 assert.deepEqual(seen, ["xabc"]);
1262 prompt.close();
1263 screen.advance();
1264 screen.cancel();
1265 });
1266
1267 test("the prompt steps aside for a widget that takes the keyboard", () => {
1268 const screen = new testing.MockScreen();
1269 const { language } = manualLanguage();
1270 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
1271 screen.advance();
1272 screen.type("abc");
1273 const seen: string[] = [];
1274 const question = screen.startWidget({
1275 format: () => "sure? (y/n)",
1276 input: (event) => seen.push(event.key),
1277 });
1278 screen.advance();
1279 assert.equal(screen.terminal.text, "sure? (y/n)");
1280 screen.type("y");
1281 question?.stop();
1282 screen.advance();
1283 assert.deepEqual(seen, ["y"]);
1284 assert.equal(screen.terminal.text, "> abc");
1285
1286 // ctrl+l clears, and ctrl+d on an empty line closes
1287 screen.writeOutput("some logs\n");
1288 screen.type("\x0c");
1289 screen.advance();
1290 assert.equal(screen.terminal.text, "> abc");
1291 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 5 });
1292 screen.type("\x01\x04\x04\x04\x04");
1293 screen.advance();
1294 ASSERT(!screen.reading);
1295 assert.equal(prompt.editor.text, "");
1296 screen.cancel();
1297 });
1298
1299 test("history is kept in a file", async () => {
1300 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "repl-test-"));
1301 const file = path.join(directory, "nested", "history");
1302 try {
1303 const language = { ...manualLanguage().language, eval: async () => {} };
1304 // the second time around, the first line is recalled and added to
1305 for (const lines of ["one\rf({\r1\r})\r", "\x1b[A\x1b[A\x1b[A\x1b[Atwo\r"]) {
1306 const screen = new testing.MockScreen();
1307 using _ = new repl.Repl({ interface: screen, language, history: file });
1308 screen.advance();
1309 screen.type(lines);
1310 await settle(screen);
1311 }
1312 assert.equal(fs.readFileSync(file, "utf-8"), "\"one\"\n\"f({\\n 1\\n})\"\n\"onetwo\"\n");
1313 } finally {
1314 fs.rmSync(directory, { recursive: true });
1315 }
1316 });
1317});
1318
1319/**
1320 * two ends that pass messages the way a process and its child do: later, and
1321 * as JSON. the first can also lose the other.
1322 */
1323function channels(): [repl.Channel & { disconnect(): void }, repl.Channel] {
1324 let connected = true;
1325 const listeners = [new Map<(message?: unknown) => void, string>(), new Map<(message?: unknown) => void, string>()];
1326 const emit = (end: number, event: string, message?: unknown) => {
1327 for (const [listener, wanted] of [...UNWRAP(listeners[end])]) {
1328 if (wanted === event) listener(message);
1329 }
1330 };
1331 const end = (mine: number) => ({
1332 send(message: unknown, callback?: (error: Error | null) => void) {
1333 const copy = JSON.stringify(message);
1334 queueMicrotask(() => {
1335 callback?.(connected ? null : new Error("channel closed"));
1336 if (connected) emit(1 - mine, "message", JSON.parse(copy));
1337 });
1338 },
1339 on: (event: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).set(listener, event),
1340 off: (_: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).delete(listener),
1341 disconnect() {
1342 connected = false;
1343 emit(mine, "disconnect");
1344 },
1345 });
1346 return [end(0), end(1)];
1347}
1348
1349describe("remote", () => {
1350 const language = repl.javascript({ globals: { replTestRemote: { answer: 42 } } });
1351 /** what evaluating `code` at the other end prints here, as text without colors */
1352 const printed = async (far: repl.Language, code: string) => {
1353 const lines: string[] = [];
1354 await far.eval(code, (value) => lines.push(ansi.strip(util.inspect(value))));
1355 return lines;
1356 };
1357
1358 test("evaluates where the language is served, and prints here", async () => {
1359 const [here, there] = channels();
1360 using _ = repl.serve(language, there);
1361 const far = repl.remote(here);
1362 assert.deepEqual(await printed(far, "replTestRemote"), ["{ answer: 42 }"]);
1363 assert.deepEqual(await printed(far, "await Promise.resolve('text')"), ["'text'"]);
1364 assert.deepEqual(await printed(far, "undefined"), []);
1365 // what is thrown there is reported from there, as it has the trace
1366 const [report] = await printed(far, "function replTestRemoteThrow() { null.x }\nreplTestRemoteThrow()");
1367 ASSERT(report?.includes("reading 'x'") && report.includes("replTestRemoteThrow"), report);
1368 // the rest of the language needs no values, and stays here
1369 assert.equal(far.tokenizer, language.tokenizer);
1370 ASSERT(far.incomplete?.("f(", "(r"));
1371 });
1372
1373 test("completes from there, in the menu of an editor here", async () => {
1374 const [here, there] = channels();
1375 using _ = repl.serve(language, there);
1376 const editor = new repl.Editor(repl.remote(here));
1377 await editor.run(() => editor.type("replTestRemote."));
1378 assert.deepEqual(editor.menu?.options.slice(0, 2), ["answer", "__defineGetter__"]);
1379 await editor.run(() => editor.type("ans"));
1380 press(editor, "tab");
1381 assert.equal(editor.text, "replTestRemote.answer");
1382 });
1383
1384 test("messages that are not its own pass by, and a lost process is said to be", async () => {
1385 const [here, there] = channels();
1386 using stop = repl.serve(language, there);
1387 const far = repl.remote(here);
1388 UNWRAP(here.send)({ other: "traffic" }, () => {});
1389 UNWRAP(there.send)({ other: "traffic" }, () => {});
1390 assert.deepEqual(await printed(far, "1 + 1"), ["2"]);
1391
1392 // what was being evaluated when the process is lost is given up on
1393 const gone = ["the process that was asked is gone"];
1394 const waiting = printed(far, "await new Promise((resolve) => globalThis.replTestRemoteDone = resolve)");
1395 await new Promise((resolve) => setImmediate(resolve));
1396 here.disconnect();
1397 assert.deepEqual(await waiting, gone);
1398 Reflect.get(globalThis, "replTestRemoteDone")();
1399 // and so is what is asked of it afterwards
1400 assert.deepEqual(await printed(far, "1"), gone);
1401 assert.equal(await far.complete?.("process.", 8, false, "punctuation"), null);
1402 stop();
1403 });
1404});
1405
1406describe("javascript", () => {
1407 const language = repl.javascript({ globals: { replTestGlobal: { answer: 42 } } });
1408 /** the values that evaluating `code` prints */
1409 const printed = async (code: string) => {
1410 const values: unknown[] = [];
1411 await language.eval(code, (value) => values.push(value));
1412 return values;
1413 };
1414
1415 test("evaluates in the global scope, as a console would", async () => {
1416 assert.deepEqual(await printed("1 + 1"), [2]);
1417 assert.deepEqual(await printed("replTestGlobal.answer"), [42]);
1418 assert.equal((await printed("globalThis"))[0], globalThis);
1419 assert.deepEqual(await printed("({ a: [1, { b: 2 }] })"), [{ a: [1, { b: 2 }] }]);
1420
1421 // declarations persist, and can be made again
1422 assert.deepEqual(await printed("let replTestLet = 5"), []);
1423 assert.deepEqual(await printed("const replTestConst = replTestLet * 2; replTestConst"), [10]);
1424 assert.deepEqual(await printed("let replTestLet = 7; replTestLet"), [7]);
1425 assert.deepEqual(await printed("await Promise.resolve(replTestLet + 1)"), [8]);
1426 assert.deepEqual(await printed("const replTestAwait = await 3; replTestAwait"), [3]);
1427 });
1428
1429 test("values come back as themselves", async () => {
1430 assert.deepEqual(await printed("undefined"), []);
1431 assert.deepEqual(
1432 await printed("[null, 0 / 0, -0, -1 / 0, 2n ** 70n, Symbol.iterator, 'text']"),
1433 [[null, NaN, -0, -Infinity, 2n ** 70n, Symbol.iterator, "text"]],
1434 );
1435 for (const code of ["null", "0 / 0", "-0", "-1 / 0", "2n ** 70n", "Symbol.iterator", "'text'"]) {
1436 assert.deepEqual(await printed(code), [(0, eval)(code)]);
1437 }
1438 // a promise that was not awaited is the value, not what it resolves to
1439 const [promise] = await printed("new Promise(() => {})");
1440 ASSERT(promise instanceof Promise);
1441 });
1442
1443 test("errors come back as themselves", async () => {
1444 const error = await printed("function replTestThrow() { null.x }\nreplTestThrow()")
1445 .catch((error) => error);
1446 ASSERT(error instanceof TypeError);
1447 // the trace ends where the entered code does
1448 assert.match(UNWRAP(error.stack), /^TypeError: .*\n at replTestThrow \(.*\)$/);
1449 const late = await printed("await null; throw new Error('late')").catch((error) => error);
1450 assert.equal(late.stack, "Error: late");
1451
1452 await assert.rejects(printed("await Promise.reject(new RangeError('late'))"), RangeError);
1453 await assert.rejects(printed("x = ;"), SyntaxError);
1454 assert.equal(await printed("throw 5").catch((error) => error), 5);
1455 });
1456
1457 test("import() works, though the inspector's scripts cannot", async () => {
1458 assert.deepEqual(await printed("(await import('node:os')).EOL"), [os.EOL]);
1459 // relative to the working directory
1460 assert.deepEqual(
1461 await printed("const { clamp } = await import(\"./lib/math.ts\"); clamp(7, 0, 5)"),
1462 [5],
1463 );
1464 assert.deepEqual(await printed("(await import /* c */ ('node:os')).EOL"), [os.EOL]);
1465 // a property or a method of that name is left alone
1466 assert.deepEqual(await printed("({ import: (x) => x }).import(4)"), [4]);
1467 assert.deepEqual(await printed("({ import(x) { return x } }).import(5)"), [5]);
1468 assert.deepEqual(await printed("class ReplTestImport { static import(x) { return x } }"), []);
1469 });
1470
1471 test("knows when a line wants another", () => {
1472 const incomplete = (code: string) => UNWRAP(language.incomplete)(code, stateAfter(code));
1473 for (
1474 const code of [
1475 "f(",
1476 "x = {",
1477 "`a${",
1478 "`a",
1479 "/* a",
1480 "if (x)",
1481 "x =",
1482 "1 +",
1483 "a.",
1484 "class A",
1485 "do x",
1486 "await f(1,\n2) +",
1487 "if (x) {\n} else",
1488 ]
1489 ) {
1490 ASSERT(incomplete(code), code);
1491 }
1492 for (
1493 const code of [
1494 "1 + 1",
1495 "f()",
1496 "}",
1497 "f(})",
1498 "(]",
1499 "'abc",
1500 "x = ;",
1501 "const b = await 1",
1502 "x // c",
1503 "/* a */",
1504 "if (x) {\n}",
1505 ]
1506 ) {
1507 ASSERT(!incomplete(code), code);
1508 }
1509 // the lexer takes these slashes for division and so sees brackets open,
1510 // but a line that parses can always be entered
1511 const misread = ["if (x) /\\(/.test(s)", "while (m) /`/.test(s)", "f({} / 2)"];
1512 for (const code of misread) {
1513 ASSERT(stateAfter(code).length > 1 && !incomplete(code), code);
1514 }
1515 // lines are counted the way the parser counts them
1516 ASSERT(incomplete("'a\u{2028}' +"));
1517 });
1518
1519 test("completes names and properties", async () => {
1520 const complete = (code: string, asked = true) => {
1521 const kind = repl.tokenize(language.tokenizer, code).at(-1)?.kind ?? "";
1522 const found = UNWRAP(language.complete)(code, code.length, asked, kind);
1523 // it is in this process, so there is nothing to wait for
1524 ASSERT(!(found instanceof Promise));
1525 return found;
1526 };
1527 /** what is on offer once `keys` are pressed, or the text if nothing is */
1528 const offered = (...keys: string[]) => {
1529 const editor = new repl.Editor(language);
1530 press(editor, ...keys);
1531 return editor.menu?.options ?? editor.text;
1532 };
1533
1534 // members come up unasked, an object's own before those it inherits
1535 assert.deepEqual(offered("process.ver").slice(0, 2), ["version", "versions"]);
1536 assert.equal(offered("f(Math . ma")[0], "max");
1537 assert.equal(offered("[...Math.ma")[0], "max");
1538 assert.equal(complete("replTestGlobal?.answer.toFi")?.from, 23);
1539 assert.equal(offered("replTestGlobal?.answer.toFi", "tab"), "replTestGlobal?.answer.toFixed");
1540 assert.deepEqual(UNWRAP(complete("replTestGlobal.")).options.slice(0, 2), ["answer", "__defineGetter__"]);
1541 // names in scope do not, until asked for
1542 assert.equal(offered("x = proc"), "x = proc");
1543 assert.equal(offered("x = proc", "tab"), "x = process");
1544 ASSERT(UNWRAP(complete("awa")).options.includes("await"));
1545 ASSERT(UNWRAP(complete("globalThis.")).options.includes("Array"));
1546
1547 // only chains of names are evaluated
1548 assert.equal(complete("[]."), null);
1549 assert.equal(complete("'text'.le"), null);
1550 assert.equal(complete("f().process.ar"), null);
1551 assert.equal(complete("1 + "), null);
1552 assert.deepEqual(complete("replTestMissing.x"), { from: 16, options: [] });
1553 // nor is there anything to complete in text that is not code
1554 assert.equal(complete("'see process.ar"), null);
1555 assert.equal(complete("x // process.ar"), null);
1556 assert.deepEqual(offered("`${process.ar").slice(0, 3), ["arch", "argv", "argv0"]);
1557
1558 // names declared at the prompt are known
1559 await printed("class ReplTestClass {}");
1560 assert.equal(offered("new ReplTestCl", "tab"), "new ReplTestClass");
1561
1562 // a getter is not run to find out what it would return
1563 let ran = false;
1564 Object.assign(globalThis, {
1565 replTestGetter: {
1566 get value() {
1567 ran = true;
1568 return {};
1569 },
1570 },
1571 });
1572 assert.deepEqual(offered("replTestGetter.va"), ["value", "valueOf"]);
1573 assert.deepEqual(complete("replTestGetter.value."), { from: 21, options: [] });
1574 ASSERT(!ran);
1575 });
1576});
1577
1578import assert from "node:assert/strict";
1579import * as fs from "node:fs";
1580import * as os from "node:os";
1581import * as path from "node:path";
1582import * as util from "node:util";
1583import { describe, test } from "vitest";
1584import { ASSERT, UNWRAP } from "./assert.ts";
1585import * as repl from "./repl.ts";
1586import * as ansi from "./string/ansi.ts";
1587import * as testing from "./testing.ts";