authorclover caruso <git@paperclover.net> 2026-10-11 16:02:04-07:00
committerclover caruso <git@paperclover.net> 2026-10-12 14:53:33-07:00
log3c3cdcccc9b633ab2ea0e14158e780bab6bbed15
treec6265b919f77cf39d42dc026ef828e28f2e78597
parent27372f562b13b96c1bf165339923d5f402753428
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat(lib/repl): interactive prompt for long running programs

closes #69 Assisted-by: claude-opus-5.5

13 files changed, 5182 insertions(+), 81 deletions(-)

lib/deno.json+1
...@@ -20,6 +20,7 @@...@@ -20,6 +20,7 @@
20 "./node": "./node.ts",20 "./node": "./node.ts",
21 "./progress": "./progress.ts",21 "./progress": "./progress.ts",
22 "./queue": "./queue.ts",22 "./queue": "./queue.ts",
23 "./repl": "./repl.ts",
23 "./stream": "./stream.ts",24 "./stream": "./stream.ts",
24 "./string": "./string.ts",25 "./string": "./string.ts",
25 "./string/ansi": "./string/ansi.ts",26 "./string/ansi": "./string/ansi.ts",
lib/examples/repl.ts created+83
...@@ -0,0 +1,83 @@
1// a pretend media server. it serves requests on its own, and the prompt is
2// there to poke at it meanwhile: try `server.`, `server.bs`,
3// `await delay(500)`, `/encode 3`, and `/deploy`.
4const console = log.scoped("http");
5const server = {
6 requests: 0,
7 bytesSent: 0,
8 visitors: new Map<string, number>(),
9 startedAt: new Date(),
10};
11
12new repl.Repl({
13 language: repl.javascript({ globals: { server, delay: async.delay } }),
14 commands: {
15 // "/encode 3" encodes three videos at once, each a bar under the prompt
16 async encode(args, node) {
17 const files = Array.from({ length: Number(args) || 2 }, (_, i) => `video-${i + 1}.mov`);
18 await Promise.all(files.map((file) => encode(node, file)));
19 },
20 // a blocking command has the terminal to itself until it is done
21 deploy: {
22 block: true,
23 complete: () => ({ from: 0, options: ["production", "staging", "preview"] }),
24 async execute(target, node) {
25 for (const step of ["build", "upload", "restart"]) {
26 using task = node.start(step);
27 for (const part of ["pages", "styles", "scripts"]) {
28 using _ = task.start(`${step} ${part}`);
29 await async.delay(250 + Math.random() * 250);
30 task.log.info(`${part} ok`);
31 }
32 }
33 node.log.info(`deployed to ${target || "production"}`);
34 },
35 },
36 },
37 prompt: "$ ",
38 style: "lines",
39 title: "media server",
40 placeholder: "javascript, or / for a command",
41 history: ".clover/repl-history",
42});
43
44async function encode(command: progress.Node, file: string) {
45 const size = (40 + Math.random() * 80) * 1e6;
46 using node = command.start(file, { total: size, units: "bytes" });
47 while (node.value < size) {
48 await async.delay(40 + Math.random() * 80);
49 node.value = Math.min(size, node.value + size * Math.random() / 25);
50 }
51 command.log.info(`encoded ${file}`);
52}
53
54// requests come in whether or not anyone is typing. `progress.start` is the
55// global progress display, which is drawn under the prompt as well.
56(async () => {
57 for (const path of cycle(["/", "/videos/7", "/blog/feed.xml", "/videos/7/stream", "/q+a"])) {
58 await async.delay(1200 + Math.random() * 1800);
59 const visitor = `10.0.0.${1 + Math.floor(Math.random() * 4)}`;
60 server.visitors.set(visitor, (server.visitors.get(visitor) ?? 0) + 1);
61 server.requests += 1;
62 server.bytesSent += Math.floor(Math.random() * 2e6);
63
64 const started = performance.now();
65 {
66 using node = progress.start(`GET ${path}`, { total: 12, showTotal: path.endsWith("stream") });
67 while (node.value < 11) {
68 await async.delay(30 + Math.random() * 60);
69 node.value += 1;
70 }
71 }
72 console.info(`GET ${path} 200 in ${Math.round(performance.now() - started)}ms`);
73 }
74})();
75
76function* cycle<T>(items: T[]) {
77 while (true) yield* items;
78}
79
80import * as async from "../async.ts";
81import * as log from "../log.ts";
82import * as progress from "../progress.ts";
83import * as repl from "../repl.ts";
lib/log.test.ts+532-8
...@@ -30,6 +30,9 @@ describe("log scopes", () => {...@@ -30,6 +30,9 @@ describe("log scopes", () => {
30 });30 });
31});31});
3232
33/** what clears a widget block from its first row, after a resize */
34const erase = "\r" + ansi.clearFullLine + ansi.cursorRight(1) + ansi.clearToEndOfScreen + "\r";
35
33// these tests for `startWidget` are built on a custom widget host36// these tests for `startWidget` are built on a custom widget host
34// implementation that buffers all bytes in memory. it is a great37// implementation that buffers all bytes in memory. it is a great
35// example of how modular the entire system is.38// example of how modular the entire system is.
...@@ -133,9 +136,8 @@ describe("log widgets", () => {...@@ -133,9 +136,8 @@ describe("log widgets", () => {
133 ansi.clearFullLine,136 ansi.clearFullLine,
134 "partial line",137 "partial line",
135 "\n",138 "\n",
136 "abc",139 ansi.clearFullLine,
137 ansi.clearToEndOfLine,140 "abc\ndefg\n",
138 "\ndefg\n",
139 ]),141 ]),
140 });142 });
141 host.writeOutput(" and more\na");143 host.writeOutput(" and more\na");
...@@ -152,9 +154,8 @@ describe("log widgets", () => {...@@ -152,9 +154,8 @@ describe("log widgets", () => {
152 " and more\n",154 " and more\n",
153 "a",155 "a",
154 "\n",156 "\n",
155 "abc",157 ansi.clearFullLine,
156 ansi.clearToEndOfLine,158 "abc\ndefg\n",
157 "\ndefg\n",
158 ]),159 ]),
159 });160 });
160 host.cancel();161 host.cancel();
...@@ -896,12 +897,92 @@ describe("log widgets", () => {...@@ -896,12 +897,92 @@ describe("log widgets", () => {
896 });897 });
897 host.resize(40);898 host.resize(40);
898 host.expectFrame(0, {899 host.expectFrame(0, {
899 merged: ansi.cursorUp(2) + "\r" + ansi.clearToEndOfScreen900 merged: ansi.cursorUp(2) + erase + testing.MockScreen.sync(["w1\nw2\n"]),
900 + testing.MockScreen.sync(["w1\nw2\n"]),
901 });901 });
902 host.cancel();902 host.cancel();
903 });903 });
904904
905 test("a narrower terminal wraps what was drawn, which is still found whole", () => {
906 // a widget as wide as the terminal, as with lines ruled across it
907 const rule = (ctx: log.WidgetFormatContext) => "-".repeat(ctx.width - 1);
908 const cursors = [undefined, { line: 1, column: 13 }, { line: 0, column: 70 }];
909 const sizes = [[100, 90], [100, 40], [100, 14], [40, 100]] as const;
910 for (const xtermjs of [false, true]) {
911 for (const cursor of cursors) {
912 for (const [from, to] of sizes) {
913 const host = new testing.MockScreen();
914 host.terminal.xtermjs = xtermjs;
915 host.resize(from);
916 host.writeOutput("log line\n");
917 const w = host.startWidget({
918 format: (ctx) => ({ text: `${rule(ctx)}\n$ state.ticks\n${rule(ctx)}`, cursor }),
919 input() {},
920 });
921 host.advance();
922 // xterm.js is known by what it answers when asked for its version
923 if (xtermjs) host.type("\x1b[>0;276;0c");
924 host.resize(to);
925 host.advance();
926 const line = "-".repeat(to - 1);
927 assert.equal(host.terminal.text, `log line\n${line}\n$ state.ticks\n${line}`);
928 w?.stop();
929 host.advance();
930 assert.equal(host.terminal.text, "log line");
931 host.cancel();
932 }
933 }
934 }
935 });
936
937 test("what is drawn before a resize is heard of does not wrap", () => {
938 const rule = (ctx: log.WidgetFormatContext) => "-".repeat(ctx.width - 1);
939 for (const xtermjs of [false, true]) {
940 const host = new testing.MockScreen();
941 host.terminal.xtermjs = xtermjs;
942 host.resize(60);
943 host.writeOutput("log line\n");
944 let ticks = 0;
945 using w = UNWRAP(host.startWidget({
946 format: (ctx) => ({
947 text: `${rule(ctx)}\n$ ${ticks}\n${rule(ctx)}\nstatus`,
948 cursor: { line: 1, column: 3 },
949 }),
950 input() {},
951 }));
952 host.advance();
953 if (xtermjs) host.type("\x1b[>0;276;0c");
954
955 // the screen has its new size before the pty behind it does
956 host.terminal.resize(40, 24);
957 for (ticks = 1; ticks <= 3; ticks += 1) {
958 w.redraw();
959 host.advance();
960 }
961 host.resize(40);
962 host.advance();
963 const line = "-".repeat(39);
964 assert.equal(host.terminal.text, `log line\n${line}\n$ 4\n${line}\nstatus`);
965 host.cancel();
966 }
967 });
968
969 test("a row that held a longer line is not wrapped as that line", () => {
970 const host = new testing.MockScreen();
971 host.resize(60);
972 host.writeOutput("log line\n");
973 let rows = ["-".repeat(59), "$ "];
974 using w = UNWRAP(host.startWidget({ format: () => rows.join("\n") }));
975 host.advance();
976 // each line moves down a row, the short one into the row of the long one
977 rows = ["status", ...rows];
978 w.redraw();
979 host.advance();
980 host.resize(40);
981 host.advance();
982 assert.equal(host.terminal.text, `log line\nstatus\n${"-".repeat(39)}\n$`);
983 host.cancel();
984 });
985
905 test("widget redraw during another widget's render", () => {986 test("widget redraw during another widget's render", () => {
906 const host = new testing.MockScreen();987 const host = new testing.MockScreen();
907 let kicked = false;988 let kicked = false;
...@@ -1093,6 +1174,449 @@ describe("log widgets", () => {...@@ -1093,6 +1174,449 @@ describe("log widgets", () => {
1093 });1174 });
1094});1175});
10951176
1177describe("widget height", () => {
1178 test("a widget that shrinks as logs are written leaves nothing behind", () => {
1179 for (const written of ["log\n", "log 1\nlog 2\n", "partial", "a\nb\nc\nd\ne\nf\n"]) {
1180 const host = new testing.MockScreen();
1181 let text = "one\ntwo\nthree\nfour\nfive";
1182 const w = host.startWidget({ format: () => text });
1183 host.advance();
1184 text = "only";
1185 host.writeOutput(written);
1186 w?.redraw();
1187 host.advance();
1188 assert.equal(host.terminal.text, written.trimEnd() + "\nonly");
1189 w?.stop();
1190 host.advance();
1191 assert.equal(host.terminal.text, written.trimEnd());
1192 host.cancel();
1193 }
1194 });
1195});
1196
1197describe("widget input", () => {
1198 // what the terminal is told around reading the keyboard: bracketed paste,
1199 // and the kitty keyboard protocol once it answers to having it
1200 const paste = "\x1b[?2004h";
1201 const unpaste = "\x1b[?2004l";
1202 const enhance = "\x1b[>1u";
1203 const unenhance = "\x1b[<u";
1204 const reading = paste + "\x1b[?u\x1b[>c";
1205 /** what a widget draws while one has the keyboard: rows that cannot wrap */
1206 const rows = (text: string) => "\x1b[?7l" + text + "\x1b[?7h";
1207
1208 /** what a widget receives for chunks of terminal input, once time has passed */
1209 function decode(...chunks: string[]): log.WidgetInput[] {
1210 const host = new testing.MockScreen();
1211 const events: log.WidgetInput[] = [];
1212 const w = host.startWidget({ format: () => "", input: (event) => events.push(event) });
1213 host.advance();
1214 for (const chunk of chunks) host.type(chunk);
1215 host.advance(50);
1216 w?.stop();
1217 host.advance();
1218 host.cancel();
1219 return events;
1220 }
1221
1222 function keys(...chunks: string[]): string[] {
1223 return decode(...chunks).map((event) => event.key);
1224 }
1225
1226 test("printable text is one event per grapheme", () => {
1227 assert.deepEqual(decode("aA "), [
1228 { key: "a", text: "a" },
1229 { key: "A", text: "A" },
1230 { key: "space", text: " " },
1231 ]);
1232 assert.deepEqual(decode("e\u{301}👩‍👩‍👧語"), [
1233 { key: "e\u{301}", text: "e\u{301}" },
1234 { key: "👩‍👩‍👧", text: "👩‍👩‍👧" },
1235 { key: "語", text: "語" },
1236 ]);
1237 });
1238
1239 test("control bytes", () => {
1240 assert.deepEqual(decode("\r\t"), [
1241 { key: "enter", text: "\n" },
1242 { key: "tab", text: "\t" },
1243 ]);
1244 assert.deepEqual(decode("\x03"), [{ key: "ctrl+c", text: "" }]);
1245 assert.deepEqual(keys("\n\x08\x7f\x00\x1c\x1f\x9b"), [
1246 "ctrl+j",
1247 "ctrl+h",
1248 "backspace",
1249 "ctrl+space",
1250 "ctrl+\\",
1251 "ctrl+_",
1252 ]);
1253 });
1254
1255 test("legacy escape sequences", () => {
1256 assert.deepEqual(keys("\x1b[A\x1b[1;5C\x1b[1;2D\x1bOH\x1b[F"), [
1257 "up",
1258 "ctrl+right",
1259 "shift+left",
1260 "home",
1261 "end",
1262 ]);
1263 assert.deepEqual(keys("\x1b[3~\x1b[3;5~\x1b[5~"), ["delete", "ctrl+delete", "pageup"]);
1264 // going backwards is not a tab to type
1265 assert.deepEqual(decode("\x1b[Z\x1b[9;2u"), [
1266 { key: "shift+tab", text: "" },
1267 { key: "shift+tab", text: "" },
1268 ]);
1269 assert.deepEqual(keys("\x1bOP\x1b[15~\x1b[1;3R\x1b[24;2~"), [
1270 "f1",
1271 "f5",
1272 "alt+f3",
1273 "shift+f12",
1274 ]);
1275 // the linux console, and modifiers after SS3
1276 assert.deepEqual(keys("\x1b[[A\x1b[[Ex\x1bO5P\x1bO1;2A"), [
1277 "f1",
1278 "f5",
1279 "x",
1280 "ctrl+f1",
1281 "shift+up",
1282 ]);
1283 assert.deepEqual(keys("ab\x1b[Ac"), ["a", "b", "up", "c"]);
1284 });
1285
1286 test("an escape prefix is alt", () => {
1287 assert.deepEqual(decode("\x1bf"), [{ key: "alt+f", text: "" }]);
1288 assert.deepEqual(keys("\x1bF\x1b\x7f\x1b\r\x1b\x1b[A\x1b\x01\x1b."), [
1289 "alt+shift+f",
1290 "alt+backspace",
1291 "alt+enter",
1292 "alt+up",
1293 "ctrl+alt+a",
1294 "alt+.",
1295 ]);
1296 // however long the run of prefixes
1297 assert.deepEqual(keys("\x1b".repeat(20000) + "[A"), ["alt+up"]);
1298 });
1299
1300 test("kitty keyboard protocol", () => {
1301 assert.deepEqual(decode("\x1b[13;2u"), [{ key: "shift+enter", text: "\n" }]);
1302 assert.deepEqual(keys("\x1b[99;5u\x1b[27u\x1b[97;6u\x1b[97;4u\x1b[127;5u"), [
1303 "ctrl+c",
1304 "escape",
1305 "ctrl+shift+a",
1306 "alt+shift+a",
1307 "ctrl+backspace",
1308 ]);
1309 assert.deepEqual(keys("\x1b[57414u\x1b[57376u\x1b[57420;5u"), ["enter", "f13", "ctrl+down"]);
1310 // shift on its own is folded into the letter
1311 assert.deepEqual(decode("\x1b[97;2u"), [{ key: "A", text: "A" }]);
1312 // the shifted key and the text can be reported by the terminal
1313 assert.deepEqual(decode("\x1b[49:33;2u"), [{ key: "!", text: "!" }]);
1314 assert.deepEqual(decode("\x1b[97;;228u"), [{ key: "a", text: "ä" }]);
1315 // lock keys are not modifiers, and releases are not presses
1316 assert.deepEqual(keys("\x1b[97;65u\x1b[97;129u"), ["a", "a"]);
1317 assert.deepEqual(keys("\x1b[97;1:3u\x1b[1;1:3A"), []);
1318 // keys without a name are dropped, as are numbers that are no character
1319 assert.deepEqual(keys("\x1b[57358u"), []);
1320 assert.deepEqual(keys("\x1b[1114112u\x1b[27;5;99999999~\x1b[97:99999999;2u"), ["A"]);
1321 assert.deepEqual(decode("\x1b[97;;99999999u"), [{ key: "a", text: "a" }]);
1322 // xterm's modifyOtherKeys says the same another way
1323 assert.deepEqual(keys("\x1b[27;2;13~\x1b[27;5;97~"), ["shift+enter", "ctrl+a"]);
1324 });
1325
1326 test("bracketed paste", () => {
1327 assert.deepEqual(decode("\x1b[200~one\r\ntwo\rthree\x1b[A\x1b[201~x"), [
1328 { key: "paste", text: "one\ntwo\nthree\x1b[A" },
1329 { key: "x", text: "x" },
1330 ]);
1331 // split across reads, even inside of its terminator
1332 assert.deepEqual(decode("\x1b[200~abc\x1b[2", "0", "1~x"), [
1333 { key: "paste", text: "abc" },
1334 { key: "x", text: "x" },
1335 ]);
1336 assert.deepEqual(decode("\x1b[200~a\x1b[2", "K\x1b[201~"), [
1337 { key: "paste", text: "a\x1b[2K" },
1338 ]);
1339 });
1340
1341 test("a paste has no deadline, unlike an escape sequence", () => {
1342 const host = new testing.MockScreen();
1343 const seen: string[] = [];
1344 const w = host.startWidget({ format: () => "", input: (event) => seen.push(event.key) });
1345 host.advance();
1346 host.type("\x1b[200~abc\x1b[2");
1347 host.advance(1000);
1348 host.type("01~");
1349 assert.deepEqual(seen, ["paste"]);
1350 // a sequence is put together across reads that come in time
1351 host.type("\x1b");
1352 host.type("[1;");
1353 host.type("5A");
1354 assert.deepEqual(seen, ["paste", "ctrl+up"]);
1355 w?.stop();
1356 host.advance();
1357 host.cancel();
1358 });
1359
1360 test("a pause resolves what is ambiguous", () => {
1361 assert.deepEqual(keys("\x1b"), ["escape"]);
1362 assert.deepEqual(keys("\x1b["), ["alt+["]);
1363 assert.deepEqual(keys("\x1bO"), ["alt+shift+o"]);
1364 // a held escape key repeats without anything in between
1365 assert.deepEqual(keys("\x1b", "\x1b", "\x1b"), ["escape", "escape", "escape"]);
1366 // a sequence that was cut off is not typed out
1367 assert.deepEqual(keys("\x1b[1;5"), []);
1368 });
1369
1370 test("sequences that are not keys", () => {
1371 // device attributes, focus in, color reports, a cursor position
1372 assert.deepEqual(keys("\x1b[?62;4c\x1b[I\x1b]11;rgb:0/0/0\x07\x1b]11;x\x1b\\\x1b[4;2R"), []);
1373 // without a terminator in sight, the introducer is the alt key
1374 assert.deepEqual(keys("\x1b]x"), ["alt+]", "x"]);
1375 // a malformed sequence is abandoned at the offending byte
1376 assert.deepEqual(keys("\x1b[1\x1b[A"), ["up"]);
1377 });
1378
1379 test("the keyboard goes to the most recently started input widget", () => {
1380 const host = new testing.MockScreen();
1381 const seen: string[] = [];
1382 const plain = host.startWidget({ format: () => "status" });
1383 host.expectFrame(0, { stderr: testing.MockScreen.sync(["status\n"]) });
1384 // a widget without `input` leaves the terminal alone
1385 ASSERT(!host.reading);
1386
1387 const a = host.startWidget({
1388 format: () => "a",
1389 input: (event) => seen.push("a:" + event.key),
1390 });
1391 host.expectFrame(0, {
1392 stderr: reading + testing.MockScreen.sync([ansi.cursorUp(1), rows(ansi.clearFullLine + "a\nstatus\n")]),
1393 });
1394 ASSERT(host.reading);
1395 host.type("x\x1b[A");
1396 ASSERT(seen.join() === "a:x,a:up");
1397
1398 const b = host.startWidget({
1399 format: () => "b",
1400 input: (event) => seen.push("b:" + event.key),
1401 });
1402 host.type("y");
1403 b?.stop();
1404 host.type("z");
1405 ASSERT(seen.join() === "a:x,a:up,b:y,a:z");
1406 host.advance();
1407 ASSERT(host.reading);
1408
1409 a?.stop();
1410 host.expectFrame(0, {
1411 stderr: unpaste + testing.MockScreen.sync([
1412 ansi.cursorUp(1) + ansi.clearToEndOfLine,
1413 ansi.cursorUp(1),
1414 "status\n",
1415 ]),
1416 });
1417 ASSERT(!host.reading);
1418 plain?.stop();
1419 host.advance();
1420 host.cancel();
1421 });
1422
1423 test("a widget places the cursor inside itself", () => {
1424 const host = new testing.MockScreen();
1425 let column = 5;
1426 const w = host.startWidget({
1427 format: () => ({ text: "> abc\nmenu", cursor: { line: 0, column } }),
1428 input() {},
1429 });
1430 host.expectFrame(0, {
1431 stderr: reading + testing.MockScreen.sync([
1432 rows("> abc\nmenu\n"),
1433 ansi.cursorUp(2) + ansi.cursorRight(5),
1434 ]),
1435 });
1436 assert.deepEqual(host.terminal.cursor, { line: 0, column: 5 });
1437
1438 // every later write first returns the cursor to its resting place
1439 column = 2;
1440 w?.redraw();
1441 host.expectFrame(0, {
1442 stderr: testing.MockScreen.sync([
1443 ansi.cursorDown(2) + "\r",
1444 ansi.cursorUp(2),
1445 rows("> abc\nmenu\n"),
1446 ansi.cursorUp(2) + ansi.cursorRight(2),
1447 ]),
1448 });
1449
1450 host.writeOutput("log line\n");
1451 host.advance();
1452 assert.equal(host.terminal.text, "log line\n> abc\nmenu");
1453 assert.deepEqual(host.terminal.cursor, { line: 1, column: 2 });
1454
1455 // foreign writes take the draw lock, which finds the cursor parked
1456 const lock = host.getDrawLock("short");
1457 host.terminal.write("foreign\n");
1458 lock.release("cursor-start-of-line");
1459 host.advance();
1460 assert.equal(host.terminal.text, "log line\nforeign\n> abc\nmenu");
1461 assert.deepEqual(host.terminal.cursor, { line: 2, column: 2 });
1462
1463 w?.stop();
1464 host.advance();
1465 assert.equal(host.terminal.text, "log line\nforeign");
1466 assert.deepEqual(host.terminal.cursor, { line: 2, column: 0 });
1467 host.cancel();
1468 });
1469
1470 test("the widget with the keyboard comes first", () => {
1471 const host = new testing.MockScreen();
1472 host.resize(80, 8);
1473 const cursor = { line: 2, column: 1 };
1474 const a = host.startWidget({ format: () => ({ text: "x\ny\nz", cursor }), input() {} });
1475 const b = host.startWidget({ format: () => ({ text: "one\ntwo", cursor }) });
1476 host.advance();
1477 // it is drawn above the others, whenever they were started, and it is
1478 // the only one to place the cursor
1479 assert.equal(host.terminal.text, "x\ny\nz\none\ntwo");
1480 assert.deepEqual(host.terminal.cursor, { line: 2, column: 1 });
1481
1482 // when there are too few rows, it is the others that go without
1483 host.resize(80, 5);
1484 host.advance();
1485 assert.equal(host.terminal.text, "x\ny\nz");
1486 assert.deepEqual(host.terminal.cursor, { line: 2, column: 1 });
1487 // a cursor on a line that got cut off is not honored
1488 host.resize(80, 3);
1489 host.advance();
1490 assert.equal(host.terminal.text, "x\ny");
1491 assert.deepEqual(host.terminal.cursor, { line: 2, column: 0 });
1492 a?.stop();
1493 b?.stop();
1494 host.advance();
1495 host.cancel();
1496 });
1497
1498 test("a long draw lock hands back the keyboard", () => {
1499 const host = new testing.MockScreen({ temporaryUnlocking: true });
1500 const w = host.startWidget({ format: () => "prompt", input() {} });
1501 host.advance();
1502 const short = host.getDrawLock("short");
1503 ASSERT(host.reading);
1504 short.release("cursor-start-of-line");
1505 host.advance();
1506
1507 const lock = host.getDrawLock("long");
1508 host.expectFrame(null, {
1509 stderr: testing.MockScreen.sync([ansi.cursorUp(1) + ansi.clearFullLine])
1510 + unpaste,
1511 });
1512 ASSERT(!host.reading);
1513 lock.release("cursor-start-of-line");
1514 // the question about the keyboard protocol is not asked twice
1515 host.expectFrame(0, {
1516 stderr: paste + testing.MockScreen.sync([rows("prompt\n")]),
1517 });
1518 ASSERT(host.reading);
1519 w?.stop();
1520 host.advance();
1521 ASSERT(!host.reading);
1522 host.cancel();
1523 });
1524
1525 test("the keyboard is let go when a draw lock outlives the widget", () => {
1526 for (const temporaryUnlocking of [true, false]) {
1527 const host = new testing.MockScreen({ temporaryUnlocking });
1528 const w = host.startWidget({ format: () => "prompt", input() {} });
1529 host.advance();
1530 // as when a prompt resolves and its result is logged right away
1531 w?.stop();
1532 host.getDrawLock("short").release("cursor-start-of-line");
1533 host.advance();
1534 ASSERT(!host.reading);
1535 ASSERT(host.hasTerminalLock === null);
1536 assert.equal(host.terminal.text, "");
1537 host.cancel();
1538 }
1539 });
1540
1541 test("a resize finds the widget from where the cursor is", () => {
1542 const host = new testing.MockScreen();
1543 const w = host.startWidget({
1544 format: () => ({ text: "one\ntwo\nthree", cursor: { line: 1, column: 2 } }),
1545 input() {},
1546 });
1547 host.advance();
1548 host.resize(80, 20);
1549 // a shorter terminal may have dropped the rows below the cursor, so it
1550 // is not moved down through them first
1551 host.expectFrame(0, {
1552 stderr: ansi.cursorUp(1) + erase + testing.MockScreen.sync([
1553 rows("one\ntwo\nthree\n"),
1554 ansi.cursorUp(2) + ansi.cursorRight(2),
1555 ]),
1556 });
1557 w?.stop();
1558 host.advance();
1559 host.cancel();
1560 });
1561
1562 test("a cursor that is not a cell is not placed", () => {
1563 const host = new testing.MockScreen();
1564 let cursor = { line: -1, column: 0 };
1565 const w = host.startWidget({ format: () => ({ text: "prompt", cursor }), input() {} });
1566 host.advance();
1567 assert.deepEqual(host.terminal.cursor, { line: 1, column: 0 });
1568 cursor = { line: 0, column: -3.5 };
1569 w?.redraw();
1570 host.advance();
1571 assert.deepEqual(host.terminal.cursor, { line: 0, column: 0 });
1572 w?.stop();
1573 host.advance();
1574 host.cancel();
1575 });
1576
1577 test("a lone escape byte is the escape key once time passes", () => {
1578 const host = new testing.MockScreen();
1579 const seen: string[] = [];
1580 const w = host.startWidget({
1581 format: () => "",
1582 input: (event) => seen.push(event.key),
1583 });
1584 host.advance();
1585 host.type("\x1b");
1586 ASSERT(seen.length === 0);
1587 host.type("[A");
1588 host.type("\x1b");
1589 host.advance(49);
1590 assert.deepEqual(seen, ["up"]);
1591 host.advance(1);
1592 assert.deepEqual(seen, ["up", "escape"]);
1593 w?.stop();
1594 host.advance();
1595 host.cancel();
1596 });
1597
1598 test("the keyboard protocol is enabled once the terminal answers", () => {
1599 const host = new testing.MockScreen();
1600 const seen: string[] = [];
1601 const options = { format: () => "", input: (event: log.WidgetInput) => seen.push(event.key) };
1602 let w = host.startWidget(options);
1603 host.expectFrame(0, { stderr: reading });
1604 host.type("\x1b[?0u\x1b[13;2u");
1605 host.expectFrame(null, { stderr: enhance });
1606 assert.deepEqual(seen, ["shift+enter"]);
1607 w?.stop();
1608 host.expectFrame(0, { stderr: unenhance + unpaste });
1609
1610 // the answer is remembered, so it is not asked for again
1611 w = host.startWidget(options);
1612 host.expectFrame(0, { stderr: paste + enhance });
1613 w?.stop();
1614 host.advance();
1615 host.cancel();
1616 });
1617});
1618
1619import assert from "node:assert/strict";
1096import { describe, test } from "vitest";1620import { describe, test } from "vitest";
1097import { ASSERT, UNWRAP } from "./assert.ts";1621import { ASSERT, UNWRAP } from "./assert.ts";
1098import * as log from "./log.ts";1622import * as log from "./log.ts";
lib/log.ts+653-70
...@@ -32,8 +32,9 @@...@@ -32,8 +32,9 @@
32 * status or input prompts. a powerful example of this system in action is32 * status or input prompts. a powerful example of this system in action is
33 * `lib/progress.ts`, which uses a widget as it's default rendering backend.33 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
34 * alongside this, a very complicated locking system is provided to ensure that34 * alongside this, a very complicated locking system is provided to ensure that
35 * widgets to not interfere with globals like `process.stdout.write`.35 * widgets to not interfere with globals like `process.stdout.write`. a widget
36 * (TODO: widgets cannot recieve "input" data yet)36 * that defines `input` receives the keyboard, which `lib/repl.ts` builds a
37 * line editor on.
37 *38 *
38 * `lib/log.ts` offers two environment integrations:39 * `lib/log.ts` offers two environment integrations:
39 * - in node.js, log messages are colored depending on the level and show40 * - in node.js, log messages are colored depending on the level and show
...@@ -314,9 +315,49 @@ export interface WidgetOptions {...@@ -314,9 +315,49 @@ export interface WidgetOptions {
314 * may get called more often than the specified `fps`.315 * may get called more often than the specified `fps`.
315 * supports color codes but not ansi cursor movements.316 * supports color codes but not ansi cursor movements.
316 */317 */
317 format(ctx: WidgetFormatContext): string | { text: string } | null;318 format(ctx: WidgetFormatContext): string | WidgetFrame | null;
318 /** 'null' to never update automatically */319 /** 'null' to never update automatically */
319 fps?: number | null;320 fps?: number | null;
321 /**
322 * receive keyboard input. the terminal is only read while some widget
323 * defines this, and events go to the most recently started one. reading
324 * puts the terminal in raw mode, so `ctrl+c` arrives here as an event
325 * instead of interrupting the process.
326 */
327 input?(event: WidgetInput): void;
328}
329
330/**
331 * one key press or paste, as given to {@linkcode WidgetOptions.input}.
332 * this API will never be altered in a breaking way.
333 */
334export interface WidgetInput {
335 /**
336 * the key, prefixed by its modifiers in the order
337 * `ctrl+alt+shift+super+hyper+meta+`. printable keys are named by the
338 * grapheme they type (`"a"`, `"A"`, `"é"`), except `"space"`. other keys
339 * are named in lowercase: `"enter"`, `"tab"`, `"backspace"`, `"delete"`,
340 * `"escape"`, `"up"`, `"down"`, `"left"`, `"right"`, `"home"`, `"end"`,
341 * `"pageup"`, `"pagedown"`, `"insert"`, `"f1"` and up. a letter combined
342 * with `ctrl`, `alt`, or `super` is lowercase with an explicit `shift+`.
343 * a bracketed paste is the key `"paste"`.
344 */
345 key: string;
346 /** what this input types, with `"\n"` for line breaks. `""` if nothing. */
347 text: string;
348}
349
350/**
351 * the object form of a widget's text.
352 * this API will never be altered in a breaking way.
353 */
354export interface WidgetFrame {
355 text: string;
356 /**
357 * where the terminal cursor rests: `line` indexes the lines of `text`, and
358 * `column` counts cells. honored for the widget receiving input.
359 */
360 cursor?: { line: number; column: number };
320}361}
321362
322/**363/**
...@@ -347,6 +388,8 @@ export interface WidgetFormatContext {...@@ -347,6 +388,8 @@ export interface WidgetFormatContext {
347 /** advisory */388 /** advisory */
348 height: number;389 height: number;
349 host?: WidgetHost;390 host?: WidgetHost;
391 /** whether this widget is the one receiving keyboard input */
392 focused?: boolean;
350}393}
351394
352/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */395/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
...@@ -383,6 +426,13 @@ export interface TerminalWidgetHostOptions {...@@ -383,6 +426,13 @@ export interface TerminalWidgetHostOptions {
383 * @default true426 * @default true
384 */427 */
385 outputSharesScreen?: boolean;428 outputSharesScreen?: boolean;
429 /**
430 * read the keyboard until the returned function is called, passing along
431 * what the terminal sends as decoded text. only called while the terminal
432 * is locked. the terminal must not echo or line-buffer in the meantime
433 * (raw mode). leave undefined if there is no keyboard to read.
434 */
435 observeInput?: (callback: (data: string) => void) => () => void;
386}436}
387437
388/**438/**
...@@ -455,6 +505,9 @@ interface WidgetState {...@@ -455,6 +505,9 @@ interface WidgetState {
455const flushSyncBytes = 65536;505const flushSyncBytes = 65536;
456const flushSyncMs = 50;506const flushSyncMs = 50;
457507
508/** how long a lone escape byte may wait to become the start of a sequence */
509const escapeMs = 50;
510
458/**511/**
459 * terminal widget rendering is done by specifying all system APIs up front in512 * terminal widget rendering is done by specifying all system APIs up front in
460 * an interface, creating an instance of the "widget host".513 * an interface, creating an instance of the "widget host".
...@@ -462,7 +515,7 @@ const flushSyncMs = 50;...@@ -462,7 +515,7 @@ const flushSyncMs = 50;
462export function createTerminalWidgetHost(515export function createTerminalWidgetHost(
463 env: TerminalWidgetHostOptions,516 env: TerminalWidgetHostOptions,
464): WidgetHost {517): WidgetHost {
465 const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env;518 const { lockTerminal, now, delay, writeOutputTemporaryLock, color, observeInput } = env;
466 const outputOnScreen = env.outputSharesScreen ?? true;519 const outputOnScreen = env.outputSharesScreen ?? true;
467520
468 let timer: async.Cancelable<void> | null = null;521 let timer: async.Cancelable<void> | null = null;
...@@ -492,6 +545,18 @@ export function createTerminalWidgetHost(...@@ -492,6 +545,18 @@ export function createTerminalWidgetHost(
492 let size: { columns: number; rows: number } | null = null;545 let size: { columns: number; rows: number } | null = null;
493 let unwatchSize: (() => void) | null = null;546 let unwatchSize: (() => void) | null = null;
494 let tempUnlock: (() => void) | null = null;547 let tempUnlock: (() => void) | null = null;
548 // how many rows above its resting place (the line under the widgets) the
549 // cursor was left by a widget's `cursor`, and in which column. all other
550 // cursor math assumes the resting place, so every write begins with
551 // `unpark()`.
552 let parked = 0;
553 let parkedColumn = 0;
554 let unwatchInput: (() => void) | null = null;
555 let escapeTimer: async.Cancelable<void> | null = null;
556 // the terminal is asked once, since an answer that comes after the
557 // keyboard was given back is typed into whatever reads it next
558 let keyboard: "unknown" | "asked" | "enhanced" = "unknown";
559 let xtermjs = false;
495560
496 // the size subscription lives exactly as long as the terminal lock, so561 // the size subscription lives exactly as long as the terminal lock, so
497 // acquisition and closing are funneled through these two functions.562 // acquisition and closing are funneled through these two functions.
...@@ -513,22 +578,92 @@ export function createTerminalWidgetHost(...@@ -513,22 +578,92 @@ export function createTerminalWidgetHost(
513 return terminal;578 return terminal;
514 }579 }
515 function closeTerminal() {580 function closeTerminal() {
581 endInput();
516 unwatchSize?.();582 unwatchSize?.();
517 unwatchSize = null;583 unwatchSize = null;
518 terminal?.close();584 terminal?.close();
519 terminal = null;585 terminal = null;
520 }586 }
521587
588 function unpark(): string {
589 const text = parked ? ansi.cursorDown(parked) + "\r" : "";
590 parked = 0;
591 return text;
592 }
593
594 function dispatchInput(events: WidgetInput[]) {
595 for (const event of events) {
596 widgets.findLast((widget) => widget.input)?.input?.(event);
597 }
598 }
599
600 function startInput(term: TerminalLock) {
601 const decoder = new InputDecoder((params, final) => {
602 if (keyboard === "asked" && final === "u" && params.startsWith("?")) {
603 keyboard = "enhanced";
604 term.writeInteractive(keyboardEnhance);
605 }
606 if (final === "c" && params === xtermjsVersion) xtermjs = true;
607 });
608 const unwatch = UNWRAP(observeInput)((data) => {
609 escapeTimer?.cancel();
610 escapeTimer = null;
611 dispatchInput(decoder.push(data));
612 // a widget may have given up the keyboard in response
613 if (decoder.pending && unwatchInput === unwatch) {
614 (escapeTimer = delay(escapeMs)).then(() => {
615 escapeTimer = null;
616 dispatchInput(decoder.flush());
617 });
618 }
619 });
620 unwatchInput = unwatch;
621
622 term.writeInteractive(
623 pasteEnable
624 + (keyboard === "enhanced" ? keyboardEnhance : "")
625 + (keyboard === "unknown" ? keyboardQuery + versionQuery : ""),
626 );
627 if (keyboard === "unknown") keyboard = "asked";
628 }
629
630 function endInput() {
631 if (!unwatchInput) return;
632 escapeTimer?.cancel();
633 escapeTimer = null;
634 UNWRAP(terminal).writeInteractive(
635 (keyboard === "enhanced" ? keyboardUnenhance : "") + pasteDisable,
636 );
637 unwatchInput();
638 unwatchInput = null;
639 }
640
522 function handleResize() {641 function handleResize() {
523 // previously drawn rows have rewrapped to the new width, so the stored
524 // line count no longer matches the screen. erasing from the widget top
525 // downward is best-effort: on shrink, rows that wrapped above the cursor
526 // cannot be recovered.
527 if (lines.length > 0 && terminal) {642 if (lines.length > 0 && terminal) {
643 // the terminal has wrapped what was drawn to its new width. the widths
644 // that were drawn say how many rows that put between the cursor and
645 // the first row. should that row have left the screen, this stops at
646 // the screen's.
647 const { columns } = UNWRAP(size);
648 const wrapped = lines.map((line) => Math.max(1, Math.ceil(ansi.widthInTerminal(line) / columns)));
649 const at = lines.length - parked;
650 let up = wrapped.slice(0, at).reduce((sum, rows) => sum + rows, 0);
651 if (parked && !xtermjs) {
652 // the cursor is kept with the text that it was on
653 up += Math.floor(parkedColumn / columns);
654 } else if (parked) {
655 // xterm.js cuts the cursor's line off instead, and moves the cursor
656 // down for every row that it adds, including those under the cursor
657 up += wrapped.slice(at + 1).reduce((sum, rows) => sum + rows - 1, 0);
658 }
528 terminal.writeInteractive(659 terminal.writeInteractive(
529 ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen,660 ansi.cursorUp(up) + "\r"
661 // short of the first cell, since tmux moves a screen that is
662 // cleared from its top left corner into the scrollback
663 + ansi.clearFullLine + ansi.cursorRight(1) + ansi.clearToEndOfScreen + "\r",
530 );664 );
531 lines = [];665 lines = [];
666 parked = 0;
532 }667 }
533 // the save register holds pre-resize coordinates; restoring it would668 // the save register holds pre-resize coordinates; restoring it would
534 // jump somewhere unrelated.669 // jump somewhere unrelated.
...@@ -565,21 +700,22 @@ export function createTerminalWidgetHost(...@@ -565,21 +700,22 @@ export function createTerminalWidgetHost(
565700
566 // trivial path when not using widgets701 // trivial path when not using widgets
567 if (!lines.length && !widgets.length) {702 if (!lines.length && !widgets.length) {
568 // a redraw can get scheduled with nothing to write (e.g. widgets torn703 if (buffer.length) {
569 // down before the timer fired); it is a no-op, not an error704 needsToRestoreCursor = false;
570 if (!buffer.length) return;705 needsToSaveCursor = false;
571 needsToRestoreCursor = false;706 if (
572 needsToSaveCursor = false;707 !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)
573 if (708 ) {
574 !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)709 const text = buffer.map((c) => c.text).join("");
575 ) {710 buffer = [];
576 const text = buffer.map((c) => c.text).join("");711 writeOutputTemporaryLock(text);
577 buffer = [];712 if (outputOnScreen) trackPartialWidth(text);
578 writeOutputTemporaryLock(text);713 } else {
579 if (outputOnScreen) trackPartialWidth(text);714 trackPartialWidth(flushChunks(acquireTerminal()));
580 } else {715 }
581 trackPartialWidth(flushChunks(acquireTerminal()));
582 }716 }
717 // with nothing to write, this still has to end what a draw lock left
718 // open when it cleared the last widgets
583 if (hasSyncStart) {719 if (hasSyncStart) {
584 acquireTerminal().writeInteractive(ansi.syncEnd);720 acquireTerminal().writeInteractive(ansi.syncEnd);
585 hasSyncStart = false;721 hasSyncStart = false;
...@@ -592,23 +728,32 @@ export function createTerminalWidgetHost(...@@ -592,23 +728,32 @@ export function createTerminalWidgetHost(
592 const { columns, rows } = UNWRAP(size);728 const { columns, rows } = UNWRAP(size);
593 let newWidgetLines: string[] = [];729 let newWidgetLines: string[] = [];
594 let next = Infinity;730 let next = Infinity;
595 // `widgets.length` is read live: a format callback may stop another widget731 const focused = widgets.findLast((widget) => widget.input);
596 for (let w = 0; w < widgets.length; w += 1) {732 // the widget with the keyboard comes first: it gets the rows it needs
597 const widget = UNWRAP(widgets[w]);733 // before the others share what is left, and it stays where it is while
598 let out: string | { text: string } | null;734 // they come and go under it
735 const order = widgets.filter((widget) => widget !== focused);
736 if (focused) order.unshift(focused);
737 let cursor: WidgetFrame["cursor"];
738 for (const widget of order) {
739 // a format callback may stop another widget, or its own
740 if (!widgets.includes(widget)) continue;
741 let out: string | WidgetFrame | null;
599 try {742 try {
600 out = widget.format({743 out = widget.format({
601 now: lastFlush,744 now: lastFlush,
602 width: columns,745 width: columns,
603 height: rows,746 height: rows,
747 focused: widget === focused,
604 });748 });
605 } catch (e) {749 } catch (e) {
606 out = e instanceof Error ? stack.format(e, color) : exceptions.message(e);750 out = e instanceof Error ? stack.format(e, color) : exceptions.message(e);
607 }751 }
752 const w = widgets.indexOf(widget);
753 if (w === -1) continue;
608 if (out == null) {754 if (out == null) {
609 widgets.splice(w, 1);755 widgets.splice(w, 1);
610 UNWRAP(internals.splice(w, 1)[0]);756 internals.splice(w, 1);
611 w -= 1;
612 continue;757 continue;
613 }758 }
614 next = Math.min(next, UNWRAP(internals[w]).frameTime);759 next = Math.min(next, UNWRAP(internals[w]).frameTime);
...@@ -618,20 +763,38 @@ export function createTerminalWidgetHost(...@@ -618,20 +763,38 @@ export function createTerminalWidgetHost(
618 if (outText === "") continue;763 if (outText === "") continue;
619 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);764 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
620 if (rowsLeft === 1) continue;765 if (rowsLeft === 1) continue;
621 newWidgetLines.push(766 const widgetLines = outText.split("\n").slice(0, rowsLeft)
622 ...outText.split("\n").slice(0, rowsLeft)767 .map((line) => ansi.trimForTerminal(line, columns - 1));
623 .map((line) => ansi.trimForTerminal(line, columns - 1)),768 newWidgetLines.push(...widgetLines);
624 );769 const at = widget === focused && typeof out !== "string" ? out.cursor : undefined;
770 if (at && Number.isInteger(at.line) && at.line >= 0 && at.line < widgetLines.length) {
771 cursor = { line: at.line, column: Math.max(0, Math.min(Math.trunc(at.column), columns - 1)) };
772 }
625 }773 }
626 newWidgetLines = newWidgetLines.slice(0, rows - 1);774 newWidgetLines = newWidgetLines.slice(0, rows - 1);
627 if (next < Infinity) redrawSoon(next);775 if (next < Infinity) redrawSoon(next);
628776
777 if (observeInput && widgets.some((widget) => widget.input)) {
778 if (!unwatchInput) startInput(term);
779 } else {
780 endInput();
781 }
782
783 // a cursor on a line that did not fit on screen stays at rest
784 const park = cursor && cursor.line < newWidgetLines.length
785 ? newWidgetLines.length - cursor.line
786 : 0;
787 const parkText = park
788 ? ansi.cursorUp(park) + ansi.cursorRight(UNWRAP(cursor).column)
789 : "";
790
629 const pCol = partialWidth % columns;791 const pCol = partialWidth % columns;
630792
631 if (newWidgetLines.length === 0) {793 if (newWidgetLines.length === 0) {
632 if (lines.length > 0) {794 if (lines.length > 0) {
633 term.writeInteractive(795 term.writeInteractive(
634 (hasSyncStart ? "" : ansi.syncStart)796 (hasSyncStart ? "" : ansi.syncStart)
797 + unpark()
635 // clear the widget space798 // clear the widget space
636 + (ansi.cursorUp(1) + ansi.clearFullLine)799 + (ansi.cursorUp(1) + ansi.clearFullLine)
637 .repeat(lines.length)800 .repeat(lines.length)
...@@ -696,6 +859,7 @@ export function createTerminalWidgetHost(...@@ -696,6 +859,7 @@ export function createTerminalWidgetHost(
696 term.writeInteractive(859 term.writeInteractive(
697 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")860 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
698 + (hasSyncStart ? "" : ansi.syncStart)861 + (hasSyncStart ? "" : ansi.syncStart)
862 + unpark()
699 + ((clearLinesTop > 0 || (pCol && lines.length > 0))863 + ((clearLinesTop > 0 || (pCol && lines.length > 0))
700 // clear the lines for buffer864 // clear the lines for buffer
701 ? (clearLinesTop > 0865 ? (clearLinesTop > 0
...@@ -721,19 +885,11 @@ export function createTerminalWidgetHost(...@@ -721,19 +885,11 @@ export function createTerminalWidgetHost(
721 // leaves the cursor wrap-deferred; this newline lands on the next885 // leaves the cursor wrap-deferred; this newline lands on the next
722 // row, which is also where `span.rows` placed the continuation.)886 // row, which is also where `span.rows` placed the continuation.)
723 (span.endWidth > 0 ? "\n" : "")887 (span.endWidth > 0 ? "\n" : "")
724 // the widget text888 + widgetText(newWidgetLines, oldLines)
725 + newWidgetLines.map((newLine, i) =>889 // rows of a taller widget block that nothing was written over
726 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)890 + (oldLines.length > newWidgetLines.length ? ansi.clearToEndOfScreen : "")
727 ? newLine + ansi.reset891 + parkText
728 : newLine)892 + ansi.syncEnd,
729 // clear rest of line if needed
730 + (oldLines[i]
731 && ansi.widthInTerminal(oldLines[i])
732 > ansi.widthInTerminal(newLine)
733 ? ansi.clearToEndOfLine
734 : "")
735 + "\n"
736 ).join("") + ansi.syncEnd,
737 );893 );
738 partialWidth = span.endWidth;894 partialWidth = span.endWidth;
739 } else {895 } else {
...@@ -744,6 +900,7 @@ export function createTerminalWidgetHost(...@@ -744,6 +900,7 @@ export function createTerminalWidgetHost(
744 term.writeInteractive(900 term.writeInteractive(
745 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")901 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
746 + (hasSyncStart ? "" : ansi.syncStart)902 + (hasSyncStart ? "" : ansi.syncStart)
903 + unpark()
747 // the first draw can land just after a partial log line; widgets904 // the first draw can land just after a partial log line; widgets
748 // must move below it. (`partialWidth` is zero whenever905 // must move below it. (`partialWidth` is zero whenever
749 // `needsToSaveCursor` is set, since releasing a draw lock resets906 // `needsToSaveCursor` is set, since releasing a draw lock resets
...@@ -756,19 +913,8 @@ export function createTerminalWidgetHost(...@@ -756,19 +913,8 @@ export function createTerminalWidgetHost(
756 : "")913 : "")
757 // skip up the widget space914 // skip up the widget space
758 + ansi.cursorUp(lines.length - clearLinesBottom)915 + ansi.cursorUp(lines.length - clearLinesBottom)
759 // the widget text916 + widgetText(newWidgetLines, lines)
760 + newWidgetLines.map((newLine, i) =>917 + parkText
761 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
762 ? newLine + ansi.reset
763 : newLine)
764 // clear rest of line if needed
765 + (lines[i]
766 && ansi.widthInTerminal(lines[i])
767 > ansi.widthInTerminal(newLine)
768 ? ansi.clearToEndOfLine
769 : "")
770 + "\n"
771 ).join("")
772 + ansi.syncEnd,918 + ansi.syncEnd,
773 );919 );
774 }920 }
...@@ -776,6 +922,24 @@ export function createTerminalWidgetHost(...@@ -776,6 +922,24 @@ export function createTerminalWidgetHost(
776 needsToRestoreCursor ||= needsToSaveCursor;922 needsToRestoreCursor ||= needsToSaveCursor;
777 needsToSaveCursor = false;923 needsToSaveCursor = false;
778 lines = newWidgetLines;924 lines = newWidgetLines;
925 parked = park;
926 parkedColumn = cursor?.column ?? 0;
927 }
928
929 /** `next` drawn from the cursor down, over rows that hold `previous` */
930 function widgetText(next: string[], previous: string[]): string {
931 const rows = next.map((line, i) =>
932 // a row that is only erased from where the shorter line ends is still
933 // as long as before to tmux, which wraps it on a narrower screen
934 (previous[i] && ansi.widthInTerminal(previous[i]) > ansi.widthInTerminal(line)
935 ? ansi.clearFullLine
936 : "")
937 + (line.includes("\x1b") && !line.endsWith(ansi.reset) ? line + ansi.reset : line)
938 + "\n"
939 ).join("");
940 // a terminal can be narrower than it last said, as its pty is told of a
941 // resize after its screen is. a row that wrapped there would be two.
942 return unwatchInput ? wrapOff + rows + wrapOn : rows;
779 }943 }
780944
781 /**945 /**
...@@ -836,6 +1000,7 @@ export function createTerminalWidgetHost(...@@ -836,6 +1000,7 @@ export function createTerminalWidgetHost(
836 const pCol = partialWidth % UNWRAP(size).columns;1000 const pCol = partialWidth % UNWRAP(size).columns;
837 term.writeInteractive(1001 term.writeInteractive(
838 ansi.syncStart1002 ansi.syncStart
1003 + unpark()
839 // clear the widget space1004 // clear the widget space
840 + (ansi.cursorUp(1) + ansi.clearFullLine)1005 + (ansi.cursorUp(1) + ansi.clearFullLine)
841 .repeat(lines.length)1006 .repeat(lines.length)
...@@ -918,14 +1083,14 @@ export function createTerminalWidgetHost(...@@ -918,14 +1083,14 @@ export function createTerminalWidgetHost(
918 },1083 },
919 getDrawLock(mode) {1084 getDrawLock(mode) {
920 if (rendering) ASSERT(locks === 0);1085 if (rendering) ASSERT(locks === 0);
921 if (locks === 0) {1086 if (locks === 0) flushAndClear(mode === "short");
922 flushAndClear(mode === "short");1087 // whoever holds a long lock may read the terminal themselves
923 if (widgets.length > 0 && terminal) {1088 if (mode === "long") endInput();
924 if (terminal.temporaryUnlock) {1089 if (locks === 0 && widgets.length > 0 && terminal) {
925 tempUnlock = terminal.temporaryUnlock();1090 if (terminal.temporaryUnlock) {
926 } else {1091 tempUnlock = terminal.temporaryUnlock();
927 closeTerminal();1092 } else {
928 }1093 closeTerminal();
929 }1094 }
930 }1095 }
931 locks += 1;1096 locks += 1;
...@@ -944,7 +1109,11 @@ export function createTerminalWidgetHost(...@@ -944,7 +1109,11 @@ export function createTerminalWidgetHost(
944 if (locks === 0) {1109 if (locks === 0) {
945 tempUnlock?.();1110 tempUnlock?.();
946 tempUnlock = null;1111 tempUnlock = null;
947 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);1112 // a redraw is also what lets go of a terminal that the lock
1113 // took from widgets which are gone by now
1114 if (buffer.length > 0 || widgets.length > 0 || terminal || hasSyncStart) {
1115 redrawSoon(0);
1116 }
948 }1117 }
949 },1118 },
950 [Symbol.dispose]() {1119 [Symbol.dispose]() {
...@@ -995,6 +1164,8 @@ export function createTerminalWidgetHost(...@@ -995,6 +1164,8 @@ export function createTerminalWidgetHost(
995 internals.splice(0, internals.length);1164 internals.splice(0, internals.length);
996 timer?.cancel();1165 timer?.cancel();
997 timer = null;1166 timer = null;
1167 // the keyboard is given back even then, as this may be the exit
1168 endInput();
998 if (!rendering) closeTerminal();1169 if (!rendering) closeTerminal();
999 },1170 },
1000 delay,1171 delay,
...@@ -1031,6 +1202,367 @@ function measureTerminalSpan(...@@ -1031,6 +1202,367 @@ function measureTerminalSpan(
1031 return { rows, endWidth: width };1202 return { rows, endWidth: width };
1032}1203}
10331204
1205/** starts and stops bracketed paste, around reading the keyboard */
1206const pasteEnable = "\x1b[?2004h";
1207const pasteDisable = "\x1b[?2004l";
1208/**
1209 * asks if the terminal supports the kitty keyboard protocol. one that does
1210 * answers with a report of `"?"` parameters ending in `"u"`.
1211 */
1212const keyboardQuery = "\x1b[?u";
1213/**
1214 * written once the terminal answers, to have it encode keys unambiguously.
1215 * this is what tells `shift+enter` from `enter`.
1216 */
1217const keyboardEnhance = "\x1b[>1u";
1218const keyboardUnenhance = "\x1b[<u";
1219/**
1220 * asks which terminal this is, as xterm would tell its version. xterm.js
1221 * has a fixed answer, and no other way of telling that it is the one.
1222 */
1223const versionQuery = "\x1b[>c";
1224const xtermjsVersion = ">0;276;0";
1225/** stops and starts the terminal continuing on the next row at its last column */
1226const wrapOff = "\x1b[?7l";
1227const wrapOn = "\x1b[?7h";
1228const pasteEnd = "\x1b[201~";
1229
1230/**
1231 * turns what a terminal sends for keyboard input into {@linkcode WidgetInput}:
1232 * legacy xterm sequences, the [kitty keyboard protocol], `modifyOtherKeys`,
1233 * and bracketed paste. stateful, as any of those can be split across reads.
1234 *
1235 * [kitty keyboard protocol]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
1236 */
1237class InputDecoder {
1238 #report: (params: string, final: string) => void;
1239 /** an escape sequence, or a paste terminator, that is cut off */
1240 #pending = "";
1241 #paste: string | null = null;
1242
1243 /**
1244 * `report` receives control sequences where the terminal describes itself
1245 * instead of a key, split into parameter bytes and the final byte.
1246 */
1247 constructor(report: (params: string, final: string) => void = () => {}) {
1248 this.#report = report;
1249 }
1250
1251 /**
1252 * true when input ended inside an escape sequence. a lone escape byte is
1253 * either the escape key or the start of a sequence, and only time can tell
1254 * them apart: if nothing else arrives shortly, call {@linkcode flush}.
1255 */
1256 get pending(): boolean {
1257 return this.#pending !== "" && this.#paste === null;
1258 }
1259
1260 /** decode the next chunk of terminal input */
1261 push(chunk: string): WidgetInput[] {
1262 const events: WidgetInput[] = [];
1263 const data = this.#pending + chunk;
1264 this.#pending = "";
1265
1266 let i = 0;
1267 while (i < data.length) {
1268 if (this.#paste !== null) {
1269 const end = data.indexOf(pasteEnd, i);
1270 if (end === -1) {
1271 const escape = data.lastIndexOf("\x1b");
1272 const cut = escape >= i && pasteEnd.startsWith(data.slice(escape))
1273 ? escape
1274 : data.length;
1275 this.#paste += data.slice(i, cut);
1276 this.#pending = data.slice(cut);
1277 break;
1278 }
1279 const text = this.#paste + data.slice(i, end);
1280 events.push({ key: "paste", text: text.replace(/\r\n?/g, "\n") });
1281 this.#paste = null;
1282 i = end + pasteEnd.length;
1283 continue;
1284 }
1285
1286 const code = data.charCodeAt(i);
1287 if (code === 0x1b) {
1288 const length = this.#escape(data, i, events);
1289 if (length === 0) {
1290 this.#pending = data.slice(i);
1291 break;
1292 }
1293 i += length;
1294 } else if (isControlCode(code)) {
1295 const event = controlKey(code, 0);
1296 if (event) events.push(event);
1297 i += 1;
1298 } else {
1299 let end = i + 1;
1300 while (end < data.length && !isControlCode(data.charCodeAt(end))) end += 1;
1301 graphemes ??= new Intl.Segmenter();
1302 for (const { segment } of graphemes.segment(data.slice(i, end))) {
1303 events.push(printableKey(segment, 0));
1304 }
1305 i = end;
1306 }
1307 }
1308 return events;
1309 }
1310
1311 /** decode input that {@linkcode pending} was waiting on, as it stands */
1312 flush(): WidgetInput[] {
1313 if (!this.pending) return [];
1314 const data = this.#pending;
1315 this.#pending = "";
1316 // a held escape key, or one pressed twice
1317 if (/^\x1b+$/.test(data)) return Array.from(data, () => namedKey("escape", 0));
1318 // anything longer than an alt-prefixed key is a sequence that was cut
1319 // off, which is dropped instead of being keyText out as text
1320 if (data.length > 2) return [];
1321 const code = data.charCodeAt(1);
1322 const event = isControlCode(code) ? controlKey(code, modAlt) : printableKey(data[1]!, modAlt);
1323 return event ? [event] : [];
1324 }
1325
1326 /**
1327 * decode the escape sequence at `data[start]`, returning its length, or
1328 * `0` if it is cut off by the end of `data`.
1329 */
1330 #escape(data: string, start: number, events: WidgetInput[]): number {
1331 // an escape prefix is how legacy terminals encode alt
1332 let last = start;
1333 while (data[last + 1] === "\x1b") last += 1;
1334 const mods = last > start ? modAlt : 0;
1335 const introducer = data[last + 1];
1336 if (introducer === undefined) return 0;
1337
1338 if (introducer === "[") {
1339 let i = last + 2;
1340 // the linux console sends its first function keys as `CSI [ A`
1341 if (data[i] === "[") {
1342 const name = linuxKeys[data[i + 1] ?? ""];
1343 if (name) events.push(namedKey(name, mods));
1344 return i + 1 < data.length ? i + 2 - start : 0;
1345 }
1346 while (inRange(data.charCodeAt(i), 0x30, 0x3f)) i += 1;
1347 const params = data.slice(last + 2, i);
1348 while (inRange(data.charCodeAt(i), 0x20, 0x2f)) i += 1;
1349 if (i >= data.length) return 0;
1350 // a byte that cannot end the sequence aborts it, and is decoded next
1351 if (!inRange(data.charCodeAt(i), 0x40, 0x7e)) return i - start;
1352
1353 const final = data[i]!;
1354 if (params === "200" && final === "~") {
1355 this.#paste = "";
1356 } else if (/^[<=>?]/.test(params)) {
1357 this.#report(params, final);
1358 } else {
1359 const event = csiKey(params, final, mods);
1360 if (event) events.push(event);
1361 }
1362 return i + 1 - start;
1363 }
1364
1365 if (introducer === "O") {
1366 // some terminals put a modifier in front of the final byte
1367 let i = last + 2;
1368 while (inRange(data.charCodeAt(i), 0x30, 0x3b)) i += 1;
1369 if (i >= data.length) return 0;
1370 const name = ss3Keys[data[i]!];
1371 if (name) {
1372 const modifier = Number(data.slice(last + 2, i).split(";").at(-1)) || 1;
1373 events.push(namedKey(name, mods | (modifier - 1) & keyModifierMask));
1374 return i + 1 - start;
1375 }
1376 } else if ("]P_^X".includes(introducer)) {
1377 // a string sequence is only recognized when whole, since waiting for
1378 // a terminator would swallow what is keyText after alt+]
1379 const bell = data.indexOf("\x07", last + 2);
1380 const terminator = data.indexOf("\x1b\\", last + 2);
1381 if (bell !== -1 && (terminator === -1 || bell < terminator)) {
1382 return bell + 1 - start;
1383 }
1384 if (terminator !== -1) return terminator + 2 - start;
1385 }
1386
1387 const code = data.codePointAt(last + 1)!;
1388 const event = isControlCode(code)
1389 ? controlKey(code, mods | modAlt)
1390 : printableKey(String.fromCodePoint(code), mods | modAlt);
1391 if (event) events.push(event);
1392 return last - start + (code > 0xffff ? 3 : 2);
1393 }
1394}
1395
1396// modifier bits, numbered as the kitty protocol encodes them
1397const modShift = 1;
1398const modAlt = 2;
1399const modCtrl = 4;
1400/** the lock keys are reported in higher bits, and are not keyModifiers */
1401const keyModifierMask = 0b111111;
1402const keyModifiers: [bit: number, prefix: string][] = [
1403 [modCtrl, "ctrl+"],
1404 [modAlt, "alt+"],
1405 [modShift, "shift+"],
1406 [8, "super+"],
1407 [16, "hyper+"],
1408 [32, "meta+"],
1409];
1410
1411const keyText: Partial<Record<string, string>> = {
1412 enter: "\n",
1413 tab: "\t",
1414 space: " ",
1415};
1416
1417const csiKeys: Partial<Record<string, string>> = {
1418 A: "up",
1419 B: "down",
1420 C: "right",
1421 D: "left",
1422 H: "home",
1423 F: "end",
1424 P: "f1",
1425 Q: "f2",
1426 S: "f4",
1427};
1428
1429const ss3Keys: Partial<Record<string, string>> = { ...csiKeys, R: "f3" };
1430
1431const linuxKeys: Partial<Record<string, string>> = {
1432 A: "f1",
1433 B: "f2",
1434 C: "f3",
1435 D: "f4",
1436 E: "f5",
1437};
1438
1439const tildeKeys: Partial<Record<number, string>> = {
1440 1: "home",
1441 2: "insert",
1442 3: "delete",
1443 4: "end",
1444 5: "pageup",
1445 6: "pagedown",
1446 7: "home",
1447 8: "end",
1448 11: "f1",
1449 12: "f2",
1450 13: "f3",
1451 14: "f4",
1452 15: "f5",
1453 17: "f6",
1454 18: "f7",
1455 19: "f8",
1456 20: "f9",
1457 21: "f10",
1458 23: "f11",
1459 24: "f12",
1460};
1461
1462/** code points that the kitty protocol assigns to keys without text */
1463const functionalKeys: Partial<Record<number, string>> = {
1464 9: "tab",
1465 13: "enter",
1466 27: "escape",
1467 32: "space",
1468 127: "backspace",
1469 57414: "enter",
1470 57417: "left",
1471 57418: "right",
1472 57419: "up",
1473 57420: "down",
1474 57421: "pageup",
1475 57422: "pagedown",
1476 57423: "home",
1477 57424: "end",
1478 57425: "insert",
1479 57426: "delete",
1480};
1481
1482let graphemes: Intl.Segmenter | null = null;
1483
1484function inRange(code: number, min: number, max: number) {
1485 return code >= min && code <= max;
1486}
1487
1488function isControlCode(code: number) {
1489 return code < 0x20 || inRange(code, 0x7f, 0x9f);
1490}
1491
1492function makeKey(base: string, mods: number, text: string): WidgetInput {
1493 const prefix = keyModifiers.map(([bit, name]) => mods & bit ? name : "").join("");
1494 return { key: prefix + base, text: mods & ~modShift ? "" : text };
1495}
1496
1497function namedKey(name: string, mods: number): WidgetInput {
1498 // shift+tab goes backwards, which is not a tab to type
1499 return makeKey(name, mods, name === "tab" && mods ? "" : keyText[name] ?? "");
1500}
1501
1502function printableKey(grapheme: string, mods: number): WidgetInput {
1503 if (grapheme === " ") return namedKey("space", mods);
1504 if (/^[a-zA-Z]$/.test(grapheme)) {
1505 if (mods & ~modShift) {
1506 if (grapheme !== grapheme.toLowerCase()) mods |= modShift;
1507 grapheme = grapheme.toLowerCase();
1508 } else if (mods) {
1509 mods = 0;
1510 grapheme = grapheme.toUpperCase();
1511 }
1512 }
1513 return makeKey(grapheme, mods, grapheme);
1514}
1515
1516/** a C0 control byte, which is how legacy terminals encode ctrl */
1517function controlKey(code: number, mods: number): WidgetInput | null {
1518 if (code === 0x0d) return namedKey("enter", mods);
1519 if (code === 0x09) return namedKey("tab", mods);
1520 if (code === 0x7f) return namedKey("backspace", mods);
1521 if (code === 0x1b) return namedKey("escape", mods);
1522 if (code === 0x00) return namedKey("space", mods | modCtrl);
1523 if (code > 0x7f) return null;
1524 const letter = String.fromCharCode(code + (code <= 0x1a ? 0x60 : 0x40));
1525 return printableKey(letter, mods | modCtrl);
1526}
1527
1528function csiKey(params: string, final: string, mods: number): WidgetInput | null {
1529 const fields = params.split(";").map((field) => field.split(":").map(Number));
1530 const first = fields[0]?.[0] || 0;
1531 mods |= Math.max(0, (fields[1]?.[0] || 1) - 1) & keyModifierMask;
1532 // key releases are only sent if asked for, but are cheap to rule out
1533 if (fields[1]?.[1] === 3) return null;
1534
1535 if (final === "u" || (final === "~" && first === 27)) {
1536 const [code = 0, shifted = 0] = final === "u" ? fields[0]! : fields[2] ?? [];
1537 const name = functionalKeys[code]
1538 ?? (inRange(code, 57376, 57398) ? `f${code - 57376 + 13}` : null);
1539 if (name) return namedKey(name, mods);
1540 // the private use area holds keys that have no name here
1541 if (!isCodePoint(code) || isControlCode(code) || inRange(code, 0xe000, 0xf8ff)) return null;
1542 const event = isCodePoint(shifted) && shifted && mods === modShift
1543 ? printableKey(String.fromCodePoint(shifted), 0)
1544 : printableKey(String.fromCodePoint(code), mods);
1545 const text = final === "u" ? fields[2] : undefined;
1546 if (text?.[0] && text.every(isCodePoint)) event.text = String.fromCodePoint(...text);
1547 return event;
1548 }
1549
1550 if (final === "~") {
1551 const name = tildeKeys[first];
1552 return name ? namedKey(name, mods) : null;
1553 }
1554 if (final === "Z") return namedKey("tab", mods | modShift);
1555 // `CSI row ; column R` is a cursor position report
1556 if (final === "R") return first <= 1 ? namedKey("f3", mods) : null;
1557 const name = csiKeys[final];
1558 return name ? namedKey(name, mods) : null;
1559}
1560
1561/** parameters are whatever digits the terminal sent */
1562function isCodePoint(code: number) {
1563 return Number.isInteger(code) && code <= 0x10ffff;
1564}
1565
1034const logColors = node.process?.stdout.isTTY ?? false;1566const logColors = node.process?.stdout.isTTY ?? false;
10351567
1036let formatLine = /* @__PURE__ */ (() => {1568let formatLine = /* @__PURE__ */ (() => {
...@@ -1182,7 +1714,7 @@ interface GlobalCommunication {...@@ -1182,7 +1714,7 @@ interface GlobalCommunication {
1182}1714}
11831715
1184const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");1716const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
1185const version = 5;1717const version = 6;
1186let global: GlobalCommunication = /* @__PURE__ */ (() => {1718let global: GlobalCommunication = /* @__PURE__ */ (() => {
1187 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {1719 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
1188 readme:1720 readme:
...@@ -1375,12 +1907,60 @@ export function defaultNodeProcessWidgetHost(...@@ -1375,12 +1907,60 @@ export function defaultNodeProcessWidgetHost(
1375 delay: async.delay,1907 delay: async.delay,
1376 color: process.stderr.isTTY,1908 color: process.stderr.isTTY,
1377 outputSharesScreen: process.stdout.isTTY,1909 outputSharesScreen: process.stdout.isTTY,
1910 observeInput: nodeProcessInput(process, (mode) => host.getDrawLock(mode)),
1378 });1911 });
1379 process.addListener("beforeExit", () => host.cancel());1912 process.addListener("beforeExit", () => host.cancel());
1380 process.addListener("exit", () => host.cancel());1913 process.addListener("exit", () => host.cancel());
1381 return host;1914 return host;
1382}1915}
13831916
1917/** reads `process.stdin` in raw mode, if it is a terminal */
1918function nodeProcessInput(
1919 process: NonNullable<typeof node.process>,
1920 getDrawLock: WidgetHost["getDrawLock"],
1921): TerminalWidgetHostOptions["observeInput"] {
1922 // asking `process.stdin` would open the stream for every program
1923 if (!node.builtin("tty")?.isatty(0)) return undefined;
1924
1925 return (callback) => {
1926 const { stdin } = process;
1927 const decoder = new TextDecoder();
1928 const onData = (chunk: Uint8Array | string) =>
1929 callback(
1930 typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }),
1931 );
1932
1933 // dying to a signal skips the exit handlers, which would leave the
1934 // terminal reading input. a long draw lock puts the terminal back, and
1935 // also removes these listeners, so that listeners running after them
1936 // (and the check below) see what the program itself installed.
1937 const signals = ["SIGINT", "SIGTERM"].map((signal) => ({
1938 signal,
1939 listener() {
1940 const lock = getDrawLock("long");
1941 if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
1942 async.delay(0).then(() => lock.release("cursor-start-of-line"));
1943 },
1944 }));
1945
1946 const wasRaw = stdin.isRaw;
1947 stdin.setRawMode(true);
1948 stdin.addListener("data", onData);
1949 // a listener alone does not restart a stream that was paused, which is
1950 // how the previous reader left it
1951 stdin.resume();
1952 for (const { signal, listener } of signals) process.prependListener(signal, listener);
1953
1954 return () => {
1955 for (const { signal, listener } of signals) process.removeListener(signal, listener);
1956 stdin.removeListener("data", onData);
1957 stdin.setRawMode(wasRaw);
1958 // a flowing stdin keeps the process alive
1959 if (stdin.listenerCount("data") === 0) stdin.pause();
1960 };
1961 };
1962}
1963
1384function defaultFallbackWidgetHost(): WidgetHost {1964function defaultFallbackWidgetHost(): WidgetHost {
1385 let warned = false;1965 let warned = false;
1386 return {1966 return {
...@@ -1409,7 +1989,10 @@ function defaultFallbackWidgetHost(): WidgetHost {...@@ -1409,7 +1989,10 @@ function defaultFallbackWidgetHost(): WidgetHost {
1409 };1989 };
1410}1990}
14111991
1412/** returns a WidgetHost from `node:process`, but without patching its methods */1992/**
1993 * returns a WidgetHost from `node:process`, but without patching its methods.
1994 * as it leaves the process alone, it does not read the keyboard either.
1995 */
1413export function simpleNodeProcessWidgetHost(1996export function simpleNodeProcessWidgetHost(
1414 process: NonNullable<typeof node.process>,1997 process: NonNullable<typeof node.process>,
1415 forceWidgetSupport = false,1998 forceWidgetSupport = false,
lib/log/stack.test.ts created+10
...@@ -0,0 +1,10 @@
1test("a trace without frames is only its message", () => {
2 const error = new RangeError("out of it");
3 error.stack = String(error);
4 assert.deepEqual(stack.parse(error), []);
5 assert.equal(stack.format(error), "[RangeError] out of it\n");
6});
7
8import assert from "node:assert/strict";
9import { test } from "vitest";
10import * as stack from "./stack.ts";
lib/log/stack.ts+4-2
...@@ -54,6 +54,8 @@ export interface Frame {...@@ -54,6 +54,8 @@ export interface Frame {
54export function parse(error: Error | string): Frame[] | null {54export function parse(error: Error | string): Frame[] | null {
55 const stack = (error as Error)?.stack ?? error;55 const stack = (error as Error)?.stack ?? error;
56 if (typeof stack !== "string") return null;56 if (typeof stack !== "string") return null;
57 // a v8 trace begins with the message, which is all there is without frames
58 if (typeof error !== "string" && stack === String(error)) return [];
57 return stack.match(regexV8Stack)59 return stack.match(regexV8Stack)
58 ? parseV8OrIe(stack)60 ? parseV8OrIe(stack)
59 : parseJscOrSpidermonkey(stack);61 : parseJscOrSpidermonkey(stack);
...@@ -117,10 +119,10 @@ export function format(error: Error, colors = false): string {...@@ -117,10 +119,10 @@ export function format(error: Error, colors = false): string {
117 }119 }
118 }120 }
119 if (frames.length) {121 if (frames.length) {
120 out += frames.map((frame) => formatFrame(frame, colors)).join("\n");122 out += frames.map((frame) => formatFrame(frame, colors)).join("\n") + "\n";
121 }123 }
122124
123 return out + "\n";125 return out;
124}126}
125127
126/** format a single stack trace frame. see `format` for more details. */128/** format a single stack trace frame. see `format` for more details. */
lib/node.ts+43-1
...@@ -23,11 +23,29 @@ interface Process {...@@ -23,11 +23,29 @@ interface Process {
23 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;23 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
24 binding?<K extends keyof Bindings>(name: K): Bindings[K] | null;24 binding?<K extends keyof Bindings>(name: K): Bindings[K] | null;
25 addListener(event: string, callback: () => void): this;25 addListener(event: string, callback: () => void): this;
26 prependListener(event: string, callback: () => void): this;
27 removeListener(event: string, callback: () => void): this;
28 listenerCount(event: string): number;
29 kill(pid: number, signal: string): boolean;
30 pid: number;
31 platform: string;
26 versions: { node?: string; bun?: string; deno?: string };32 versions: { node?: string; bun?: string; deno?: string };
33 stdin: TtyInput;
27 stdout: Tty;34 stdout: Tty;
28 stderr: Tty;35 stderr: Tty;
29}36}
3037
38/** the raw mode members are only present when the stream is a tty */
39interface TtyInput {
40 isRaw: boolean;
41 setRawMode(mode: boolean): this;
42 addListener(event: "data", callback: (chunk: Uint8Array | string) => void): this;
43 removeListener(event: "data", callback: (chunk: Uint8Array | string) => void): this;
44 listenerCount(event: string): number;
45 pause(): this;
46 resume(): this;
47}
48
31interface Tty {49interface Tty {
32 isTTY: boolean;50 isTTY: boolean;
33 /** only present when the stream is a tty */51 /** only present when the stream is a tty */
...@@ -45,20 +63,44 @@ interface Tty {...@@ -45,20 +63,44 @@ interface Tty {
45 * "@types/node" package.63 * "@types/node" package.
46 */64 */
47interface Builtins {65interface Builtins {
66 "child_process": {
67 spawnSync(command: string, args: string[], options: { stdio: "ignore" }): unknown;
68 };
48 "fs": {69 "fs": {
49 existsSync(file: string): boolean;70 existsSync(file: string): boolean;
50 readFileSync(file: string, encoding: "utf-8"): string;71 readFileSync(file: string, encoding: "utf-8"): string;
51 readFileSync(file: string): Uint8Array;72 readFileSync(file: string): Uint8Array;
73 writeFileSync(file: string, data: string, options?: { mode?: number }): void;
74 appendFileSync(file: string, data: string, options?: { mode?: number }): void;
75 mkdirSync(dir: string, options: { recursive: true }): void;
76 };
77 "inspector": {
78 Session: new() => {
79 connect(): void;
80 post(
81 method: string,
82 params: object,
83 // the result is whatever the Chrome DevTools Protocol method returns
84 callback: (error: Error | null, result: any) => void,
85 ): void;
86 };
52 };87 };
53 "path": {88 "path": {
54 isAbsolute(file: string): boolean;89 isAbsolute(file: string): boolean;
55 dirname(file: string): string;90 dirname(file: string): string;
56 basename(file: string): string;91 basename(file: string): string;
57 join(...parts: string[]): string;92 join(...parts: string[]): string;
93 resolve(...parts: string[]): string;
58 sep: string;94 sep: string;
59 };95 };
96 "tty": {
97 isatty(fd: number): boolean;
98 };
99 "url": {
100 pathToFileURL(file: string): URL;
101 };
60 "util": {102 "util": {
61 inspect(object: unknown): string;103 inspect(object: unknown, options?: { colors?: boolean }): string;
62 formatWithOptions(104 formatWithOptions(
63 options: { colors?: boolean },105 options: { colors?: boolean },
64 ...args: unknown[]106 ...args: unknown[]
lib/readme.changes.md+10
...@@ -1,5 +1,15 @@...@@ -1,5 +1,15 @@
1# notable changes in clover's typescript library1# notable changes in clover's typescript library
22
3## v8
4
5### features
6
7- new library `repl`: an interactive prompt to add to long running programs.
8- `log`
9 - widgets can now read keyboard input.
10 - finally fixes resize corruptions for long running widgets
11- `string/ansi`: add `inverse`, `resetInverse`, and `cursorHome`.
12
3## v713## v7
414
5### features15### features
lib/readme.md+1
...@@ -27,6 +27,7 @@ depend on each other. there are no external dependencies; code is easy to audit....@@ -27,6 +27,7 @@ depend on each other. there are no external dependencies; code is easy to audit.
27- `node` - the Node.js javascript runtime27- `node` - the Node.js javascript runtime
28- `progress` - cli progress bars, nesting status indication28- `progress` - cli progress bars, nesting status indication
29- `queue` - priority queue with rescheduling29- `queue` - priority queue with rescheduling
30- `repl` - interactive prompt for long running programs
30- `stream` - ReadableStream helpers31- `stream` - ReadableStream helpers
31- `string` - trival helpers32- `string` - trival helpers
32- `string/ansi` - cursor and color constants33- `string/ansi` - cursor and color constants
lib/repl.test.ts created+1581
...@@ -0,0 +1,1581 @@
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 evaluating[0]?.reject(Object.assign(new Error("odd"), { big: 1n }));
947 await settle(screen);
948 assert.deepEqual(linesOf(screen), ["> x", "odd", "> ab"]);
949 // as does a key binding that throws
950 screen.type("\tc");
951 screen.advance();
952 assert.equal(linesOf(screen).at(-1), "> abc");
953 ASSERT(linesOf(screen).includes("Unexpected null"));
954
955 // closing forgets what was typed ahead
956 screen.type("\rd\r");
957 prompt.close();
958 evaluating[1]?.resolve(undefined);
959 await settle(screen);
960 assert.deepEqual(evaluating.map((entry) => entry.code), ["x", "abc"]);
961 screen.cancel();
962 });
963
964 test("an unfinished line continues", () => {
965 const screen = new testing.MockScreen();
966 const { language, evaluating } = manualLanguage();
967 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
968 screen.advance();
969 screen.type("f({\r");
970 screen.advance();
971 assert.equal(screen.terminal.text, "> f({\n…");
972 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 });
973 screen.type("a\r})\r");
974 screen.advance();
975 assert.equal(evaluating[0]?.code, "f({\n a\n})");
976 assert.equal(screen.terminal.text, "> f({\n… a\n… })");
977 // a blank line is not evaluated at all
978 prompt.close();
979 screen.advance();
980 screen.cancel();
981 });
982
983 test("pasted text is not entered line by line", () => {
984 const screen = new testing.MockScreen();
985 const { language, evaluating } = manualLanguage();
986 using _ = new repl.Repl({ interface: screen, language, prompt: "> " });
987 screen.advance();
988 screen.type("\x1b[200~a\r\n\tb\x1b[201~");
989 screen.advance();
990 assert.equal(screen.terminal.text, "> a\n… b");
991 assert.equal(evaluating.length, 0);
992 screen.type("\x03");
993 screen.advance();
994 assert.equal(screen.terminal.text, ">");
995 screen.cancel();
996 });
997
998 test("commands run beside the prompt", async () => {
999 const screen = new testing.MockScreen();
1000 const calls: string[] = [];
1001 let finish = () => {};
1002 using prompt = new repl.Repl({
1003 interface: screen,
1004 language: null,
1005 prompt: "> ",
1006 commands: {
1007 greet: {
1008 async execute(args, node) {
1009 ASSERT(this === prompt);
1010 calls.push(args);
1011 node.log.info("hello", args);
1012 using _ = node.start("counting", { total: 4, value: 1 });
1013 await new Promise<void>((resolve) => finish = resolve);
1014 },
1015 },
1016 "wait-up": {
1017 block: true,
1018 execute: () => new Promise<void>((resolve) => finish = resolve),
1019 },
1020 // names that cannot be typed as a command are never run or offered
1021 "": { execute: () => void calls.push("nameless") },
1022 "gr eet": { execute: () => void calls.push("spaced") },
1023 fail: {
1024 execute() {
1025 throw new Error("no");
1026 },
1027 },
1028 },
1029 });
1030 screen.advance();
1031
1032 // the names of commands come up at the slash, lined up under it
1033 screen.type("/");
1034 screen.advance();
1035 assert.equal(screen.terminal.text, "> /\n fail\n greet\n wait-up");
1036 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 });
1037 // the first is highlighted, which enter takes before it enters the line
1038 screen.type("g\r");
1039 screen.advance();
1040 assert.equal(linesOf(screen).at(-1), "> /greet");
1041 assert.deepEqual(calls, []);
1042 screen.type(" to all \r");
1043 await settle(screen, 1);
1044 assert.deepEqual(calls, ["to all"]);
1045 const bar = "███ ";
1046 assert.equal(
1047 screen.terminal.text,
1048 ["> /greet to all", ">", "⠋ /greet to all", `└─ ${bar} [1/4] counting`].join("\n"),
1049 );
1050 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1051
1052 // the prompt keeps working above the progress display
1053 screen.type("hi");
1054 screen.advance();
1055 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 });
1056 finish();
1057 await settle(screen, 1);
1058 // what the command logged to its node comes out once it is done
1059 const logged = ["> /greet to all", "[logs from /greet to all]", "info: hello to all"];
1060 assert.deepEqual(linesOf(screen), [...logged, "> hi"]);
1061
1062 screen.type("\r");
1063 await settle(screen);
1064 assert.deepEqual(linesOf(screen), [...logged, "> hi", "not a command", ">"]);
1065
1066 // unless the command asked for the prompt to wait
1067 screen.type("/wait-up\r/fail\r");
1068 await settle(screen, 1);
1069 assert.deepEqual(linesOf(screen).slice(-2), ["> /wait-up", "⠋ /wait-up"]);
1070 finish();
1071 await settle(screen, 1);
1072 await settle(screen, 1);
1073 // a failure is reported with its trace
1074 const lines = linesOf(screen);
1075 const failed = lines.lastIndexOf("> /fail");
1076 assert.deepEqual(lines.slice(failed - 1, failed + 2), ["> /wait-up", "> /fail", "no"]);
1077 assert.equal(lines.at(-1), ">");
1078
1079 prompt.close();
1080 screen.advance(1);
1081 screen.cancel();
1082 });
1083
1084 test("completions are listed under the prompt", async () => {
1085 const screen = new testing.MockScreen();
1086 const { language, evaluating } = manualLanguage();
1087 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
1088 screen.advance();
1089 // asked for with tab
1090 screen.type("a\t");
1091 screen.advance();
1092 assert.equal(screen.terminal.text, "> a\n alpha\n alpine");
1093 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 });
1094 // the arrows choose, enter takes the choice, and enter again the line
1095 screen.type("\x1b[B\r");
1096 screen.advance();
1097 assert.equal(screen.terminal.text, "> alpine");
1098 assert.equal(evaluating.length, 0);
1099 screen.type("\r");
1100 evaluating[0]?.resolve(undefined);
1101 await settle(screen);
1102 assert.equal(evaluating[0]?.code, "alpine");
1103
1104 // or coming up as the language sees fit, here after a colon
1105 screen.type("x:");
1106 screen.advance();
1107 assert.deepEqual(linesOf(screen).slice(-4), ["> x:", " alpha", " alpine", " beta"]);
1108 // an option that is typed out leaves enter to do its usual
1109 screen.type("beta\r");
1110 screen.advance();
1111 assert.equal(evaluating[1]?.code, "x:beta");
1112 evaluating[1]?.resolve(undefined);
1113 await settle(screen);
1114
1115 // a history search is shown there as well
1116 screen.type("\x12al");
1117 screen.advance();
1118 assert.deepEqual(linesOf(screen).slice(-2), ["> alpine", "search: al"]);
1119 assert.deepEqual(screen.terminal.cursor, { line: linesOf(screen).length - 2, column: 4 });
1120 prompt.close();
1121 screen.advance();
1122 screen.cancel();
1123 });
1124
1125 test("a command completes what follows its name", async () => {
1126 const screen = new testing.MockScreen();
1127 const ran: string[] = [];
1128 const word = (args: string) => args.lastIndexOf(" ") + 1;
1129 using prompt = new repl.Repl({
1130 interface: screen,
1131 language: manualLanguage().language,
1132 prompt: "> ",
1133 commands: {
1134 deploy: {
1135 execute: (args) => void ran.push(args),
1136 // the first word is offered unasked, and the ones after it by tab
1137 complete: (args, asked) =>
1138 asked || !args.includes(" ") ? { from: word(args), options: ["production", "preview", "staging"] } : null,
1139 },
1140 // a function is a command as well, without anything to complete
1141 plain: (args) => void ran.push("plain " + args),
1142 },
1143 });
1144 screen.advance();
1145
1146 // options are lined up under what they would stand in for
1147 const under = (column: number, option: string) => " ".repeat(column) + option;
1148 screen.type("/deploy ");
1149 screen.advance();
1150 assert.deepEqual(linesOf(screen), [
1151 "> /deploy",
1152 under(10, "production"),
1153 under(10, "preview"),
1154 under(10, "staging"),
1155 ]);
1156 // narrowed by what is typed, loosely, and taken by enter
1157 screen.type("ag");
1158 screen.advance();
1159 assert.deepEqual(linesOf(screen), ["> /deploy ag", under(10, "staging")]);
1160 screen.type("\r x");
1161 screen.advance();
1162 assert.deepEqual(linesOf(screen), ["> /deploy staging x"]);
1163 screen.type("\x7fp\t");
1164 screen.advance();
1165 assert.deepEqual(linesOf(screen), ["> /deploy staging p", under(18, "production"), under(18, "preview")]);
1166 screen.type("\x1b[B\r\r");
1167 await settle(screen, 1);
1168 assert.deepEqual(ran, ["staging preview"]);
1169
1170 // a command that has nothing to say gets no offers, the language's included
1171 screen.type("/plain a\t");
1172 screen.advance();
1173 assert.equal(linesOf(screen).at(-1), "> /plain a");
1174 assert.equal(prompt.editor.menu, null);
1175 screen.type("\r");
1176 await settle(screen, 1);
1177 assert.deepEqual(ran, ["staging preview", "plain a"]);
1178 prompt.close();
1179 screen.advance();
1180 screen.cancel();
1181 });
1182
1183 test("a row is kept under the prompt, which work is shown in", async () => {
1184 const drawn = (text: string, up: number) =>
1185 testing.MockScreen.sync([`\x1b[?7l${text}\x1b[?7h`, ansi.cursorUp(up) + ansi.cursorRight(2)]);
1186 const asking = "\x1b[?2004h\x1b[?u\x1b[>c";
1187
1188 const screen = new testing.MockScreen();
1189 let finish = () => {};
1190 const commands = { wait: { execute: () => new Promise<void>((resolve) => finish = resolve) } };
1191 using prompt = new repl.Repl({ interface: screen, language: null, prompt: "> ", commands });
1192 screen.expectFrame(0, { stderr: asking + drawn("> \n\n", 2) });
1193 screen.type("/wait\r");
1194 await settle(screen, 1);
1195 assert.equal(screen.terminal.text, "> /wait\n>\n⠋ /wait");
1196 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1197 finish();
1198 await settle(screen, 1);
1199 prompt.close();
1200 screen.advance();
1201 screen.cancel();
1202
1203 const bare = new testing.MockScreen();
1204 using none = new repl.Repl({ interface: bare, language: null, prompt: "> ", reserve: 0 });
1205 bare.expectFrame(0, { stderr: asking + drawn("> \n", 1) });
1206 none.close();
1207 bare.advance();
1208 bare.cancel();
1209 });
1210
1211 test("the prompt is styled, titled, and filled in as asked for", () => {
1212 const screen = new testing.MockScreen();
1213 screen.resize(16);
1214 const { language } = manualLanguage();
1215 using prompt = new repl.Repl({
1216 interface: screen,
1217 language,
1218 prompt: "> ",
1219 style: "lines",
1220 title: "node",
1221 placeholder: "ask away",
1222 });
1223 screen.advance();
1224 const line = "─".repeat(15);
1225 assert.equal(screen.terminal.text, ["─┤ node ├──────", "> ask away", line].join("\n"));
1226 assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 });
1227 screen.type("f(\r");
1228 screen.advance();
1229 assert.equal(screen.terminal.text, ["─┤ node ├──────", "> f(", "…", line].join("\n"));
1230 assert.deepEqual(screen.terminal.cursor, { line: 2, column: 4 });
1231 prompt.close();
1232 screen.advance();
1233 screen.cancel();
1234 });
1235
1236 test("keys can be rebound", () => {
1237 const screen = new testing.MockScreen();
1238 const { language } = manualLanguage();
1239 const seen: string[] = [];
1240 using prompt = new repl.Repl({
1241 interface: screen,
1242 language,
1243 prompt: "> ",
1244 keymap: {
1245 "ctrl+a": null,
1246 "ctrl+e": "moveLineStart",
1247 "f1": (editor, self) => {
1248 ASSERT(self === prompt);
1249 seen.push(editor.text);
1250 },
1251 },
1252 });
1253 screen.advance();
1254 screen.type("abc\x01\x05x\x1bOP");
1255 assert.deepEqual(seen, ["xabc"]);
1256 prompt.close();
1257 screen.advance();
1258 screen.cancel();
1259 });
1260
1261 test("the prompt steps aside for a widget that takes the keyboard", () => {
1262 const screen = new testing.MockScreen();
1263 const { language } = manualLanguage();
1264 using prompt = new repl.Repl({ interface: screen, language, prompt: "> " });
1265 screen.advance();
1266 screen.type("abc");
1267 const seen: string[] = [];
1268 const question = screen.startWidget({
1269 format: () => "sure? (y/n)",
1270 input: (event) => seen.push(event.key),
1271 });
1272 screen.advance();
1273 assert.equal(screen.terminal.text, "sure? (y/n)");
1274 screen.type("y");
1275 question?.stop();
1276 screen.advance();
1277 assert.deepEqual(seen, ["y"]);
1278 assert.equal(screen.terminal.text, "> abc");
1279
1280 // ctrl+l clears, and ctrl+d on an empty line closes
1281 screen.writeOutput("some logs\n");
1282 screen.type("\x0c");
1283 screen.advance();
1284 assert.equal(screen.terminal.text, "> abc");
1285 assert.deepEqual(screen.terminal.cursor, { line: 0, column: 5 });
1286 screen.type("\x01\x04\x04\x04\x04");
1287 screen.advance();
1288 ASSERT(!screen.reading);
1289 assert.equal(prompt.editor.text, "");
1290 screen.cancel();
1291 });
1292
1293 test("history is kept in a file", async () => {
1294 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "repl-test-"));
1295 const file = path.join(directory, "nested", "history");
1296 try {
1297 const language = { ...manualLanguage().language, eval: async () => {} };
1298 // the second time around, the first line is recalled and added to
1299 for (const lines of ["one\rf({\r1\r})\r", "\x1b[A\x1b[A\x1b[A\x1b[Atwo\r"]) {
1300 const screen = new testing.MockScreen();
1301 using _ = new repl.Repl({ interface: screen, language, history: file });
1302 screen.advance();
1303 screen.type(lines);
1304 await settle(screen);
1305 }
1306 assert.equal(fs.readFileSync(file, "utf-8"), "\"one\"\n\"f({\\n 1\\n})\"\n\"onetwo\"\n");
1307 } finally {
1308 fs.rmSync(directory, { recursive: true });
1309 }
1310 });
1311});
1312
1313/**
1314 * two ends that pass messages the way a process and its child do: later, and
1315 * as JSON. the first can also lose the other.
1316 */
1317function channels(): [repl.Channel & { disconnect(): void }, repl.Channel] {
1318 let connected = true;
1319 const listeners = [new Map<(message?: unknown) => void, string>(), new Map<(message?: unknown) => void, string>()];
1320 const emit = (end: number, event: string, message?: unknown) => {
1321 for (const [listener, wanted] of [...UNWRAP(listeners[end])]) {
1322 if (wanted === event) listener(message);
1323 }
1324 };
1325 const end = (mine: number) => ({
1326 send(message: unknown, callback?: (error: Error | null) => void) {
1327 const copy = JSON.stringify(message);
1328 queueMicrotask(() => {
1329 callback?.(connected ? null : new Error("channel closed"));
1330 if (connected) emit(1 - mine, "message", JSON.parse(copy));
1331 });
1332 },
1333 on: (event: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).set(listener, event),
1334 off: (_: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).delete(listener),
1335 disconnect() {
1336 connected = false;
1337 emit(mine, "disconnect");
1338 },
1339 });
1340 return [end(0), end(1)];
1341}
1342
1343describe("remote", () => {
1344 const language = repl.javascript({ globals: { replTestRemote: { answer: 42 } } });
1345 /** what evaluating `code` at the other end prints here, as text without colors */
1346 const printed = async (far: repl.Language, code: string) => {
1347 const lines: string[] = [];
1348 await far.eval(code, (value) => lines.push(ansi.strip(util.inspect(value))));
1349 return lines;
1350 };
1351
1352 test("evaluates where the language is served, and prints here", async () => {
1353 const [here, there] = channels();
1354 using _ = repl.serve(language, there);
1355 const far = repl.remote(here);
1356 assert.deepEqual(await printed(far, "replTestRemote"), ["{ answer: 42 }"]);
1357 assert.deepEqual(await printed(far, "await Promise.resolve('text')"), ["'text'"]);
1358 assert.deepEqual(await printed(far, "undefined"), []);
1359 // what is thrown there is reported from there, as it has the trace
1360 const [report] = await printed(far, "function replTestRemoteThrow() { null.x }\nreplTestRemoteThrow()");
1361 ASSERT(report?.includes("TypeError") && report.includes("replTestRemoteThrow"), report);
1362 // the rest of the language needs no values, and stays here
1363 assert.equal(far.tokenizer, language.tokenizer);
1364 ASSERT(far.incomplete?.("f(", "(r"));
1365 });
1366
1367 test("completes from there, in the menu of an editor here", async () => {
1368 const [here, there] = channels();
1369 using _ = repl.serve(language, there);
1370 const editor = new repl.Editor(repl.remote(here));
1371 await editor.run(() => editor.type("replTestRemote."));
1372 assert.deepEqual(editor.menu?.options.slice(0, 2), ["answer", "__defineGetter__"]);
1373 await editor.run(() => editor.type("ans"));
1374 press(editor, "tab");
1375 assert.equal(editor.text, "replTestRemote.answer");
1376 });
1377
1378 test("messages that are not its own pass by, and a lost process is said to be", async () => {
1379 const [here, there] = channels();
1380 using stop = repl.serve(language, there);
1381 const far = repl.remote(here);
1382 UNWRAP(here.send)({ other: "traffic" }, () => {});
1383 UNWRAP(there.send)({ other: "traffic" }, () => {});
1384 assert.deepEqual(await printed(far, "1 + 1"), ["2"]);
1385
1386 // what was being evaluated when the process is lost is given up on
1387 const gone = ["the process that was asked is gone"];
1388 const waiting = printed(far, "await new Promise((resolve) => globalThis.replTestRemoteDone = resolve)");
1389 await new Promise((resolve) => setImmediate(resolve));
1390 here.disconnect();
1391 assert.deepEqual(await waiting, gone);
1392 Reflect.get(globalThis, "replTestRemoteDone")();
1393 // and so is what is asked of it afterwards
1394 assert.deepEqual(await printed(far, "1"), gone);
1395 assert.equal(await far.complete?.("process.", 8, false, "punctuation"), null);
1396 stop();
1397 });
1398});
1399
1400describe("javascript", () => {
1401 const language = repl.javascript({ globals: { replTestGlobal: { answer: 42 } } });
1402 /** the values that evaluating `code` prints */
1403 const printed = async (code: string) => {
1404 const values: unknown[] = [];
1405 await language.eval(code, (value) => values.push(value));
1406 return values;
1407 };
1408
1409 test("evaluates in the global scope, as a console would", async () => {
1410 assert.deepEqual(await printed("1 + 1"), [2]);
1411 assert.deepEqual(await printed("replTestGlobal.answer"), [42]);
1412 assert.equal((await printed("globalThis"))[0], globalThis);
1413 assert.deepEqual(await printed("({ a: [1, { b: 2 }] })"), [{ a: [1, { b: 2 }] }]);
1414
1415 // declarations persist, and can be made again
1416 assert.deepEqual(await printed("let replTestLet = 5"), []);
1417 assert.deepEqual(await printed("const replTestConst = replTestLet * 2; replTestConst"), [10]);
1418 assert.deepEqual(await printed("let replTestLet = 7; replTestLet"), [7]);
1419 assert.deepEqual(await printed("await Promise.resolve(replTestLet + 1)"), [8]);
1420 assert.deepEqual(await printed("const replTestAwait = await 3; replTestAwait"), [3]);
1421 });
1422
1423 test("values come back as themselves", async () => {
1424 assert.deepEqual(await printed("undefined"), []);
1425 assert.deepEqual(
1426 await printed("[null, 0 / 0, -0, -1 / 0, 2n ** 70n, Symbol.iterator, 'text']"),
1427 [[null, NaN, -0, -Infinity, 2n ** 70n, Symbol.iterator, "text"]],
1428 );
1429 for (const code of ["null", "0 / 0", "-0", "-1 / 0", "2n ** 70n", "Symbol.iterator", "'text'"]) {
1430 assert.deepEqual(await printed(code), [(0, eval)(code)]);
1431 }
1432 // a promise that was not awaited is the value, not what it resolves to
1433 const [promise] = await printed("new Promise(() => {})");
1434 ASSERT(promise instanceof Promise);
1435 });
1436
1437 test("errors come back as themselves", async () => {
1438 const error = await printed("function replTestThrow() { null.x }\nreplTestThrow()")
1439 .catch((error) => error);
1440 ASSERT(error instanceof TypeError);
1441 // the trace ends where the entered code does
1442 assert.match(UNWRAP(error.stack), /^TypeError: .*\n at replTestThrow \(.*\)$/);
1443 const late = await printed("await null; throw new Error('late')").catch((error) => error);
1444 assert.equal(late.stack, "Error: late");
1445
1446 await assert.rejects(printed("await Promise.reject(new RangeError('late'))"), RangeError);
1447 await assert.rejects(printed("x = ;"), SyntaxError);
1448 assert.equal(await printed("throw 5").catch((error) => error), 5);
1449 });
1450
1451 test("import() works, though the inspector's scripts cannot", async () => {
1452 assert.deepEqual(await printed("(await import('node:os')).EOL"), [os.EOL]);
1453 // relative to the working directory
1454 assert.deepEqual(
1455 await printed("const { clamp } = await import(\"./lib/math.ts\"); clamp(7, 0, 5)"),
1456 [5],
1457 );
1458 assert.deepEqual(await printed("(await import /* c */ ('node:os')).EOL"), [os.EOL]);
1459 // a property or a method of that name is left alone
1460 assert.deepEqual(await printed("({ import: (x) => x }).import(4)"), [4]);
1461 assert.deepEqual(await printed("({ import(x) { return x } }).import(5)"), [5]);
1462 assert.deepEqual(await printed("class ReplTestImport { static import(x) { return x } }"), []);
1463 });
1464
1465 test("knows when a line wants another", () => {
1466 const incomplete = (code: string) => UNWRAP(language.incomplete)(code, stateAfter(code));
1467 for (
1468 const code of [
1469 "f(",
1470 "x = {",
1471 "`a${",
1472 "`a",
1473 "/* a",
1474 "if (x)",
1475 "x =",
1476 "1 +",
1477 "a.",
1478 "class A",
1479 "do x",
1480 "await f(1,\n2) +",
1481 "if (x) {\n} else",
1482 ]
1483 ) {
1484 ASSERT(incomplete(code), code);
1485 }
1486 for (
1487 const code of [
1488 "1 + 1",
1489 "f()",
1490 "}",
1491 "f(})",
1492 "(]",
1493 "'abc",
1494 "x = ;",
1495 "const b = await 1",
1496 "x // c",
1497 "/* a */",
1498 "if (x) {\n}",
1499 ]
1500 ) {
1501 ASSERT(!incomplete(code), code);
1502 }
1503 // the lexer takes these slashes for division and so sees brackets open,
1504 // but a line that parses can always be entered
1505 const misread = ["if (x) /\\(/.test(s)", "while (m) /`/.test(s)", "f({} / 2)"];
1506 for (const code of misread) {
1507 ASSERT(stateAfter(code).length > 1 && !incomplete(code), code);
1508 }
1509 // lines are counted the way the parser counts them
1510 ASSERT(incomplete("'a\u{2028}' +"));
1511 });
1512
1513 test("completes names and properties", async () => {
1514 const complete = (code: string, asked = true) => {
1515 const kind = repl.tokenize(language.tokenizer, code).at(-1)?.kind ?? "";
1516 const found = UNWRAP(language.complete)(code, code.length, asked, kind);
1517 // it is in this process, so there is nothing to wait for
1518 ASSERT(!(found instanceof Promise));
1519 return found;
1520 };
1521 /** what is on offer once `keys` are pressed, or the text if nothing is */
1522 const offered = (...keys: string[]) => {
1523 const editor = new repl.Editor(language);
1524 press(editor, ...keys);
1525 return editor.menu?.options ?? editor.text;
1526 };
1527
1528 // members come up unasked, an object's own before those it inherits
1529 assert.deepEqual(offered("process.ver").slice(0, 2), ["version", "versions"]);
1530 assert.equal(offered("f(Math . ma")[0], "max");
1531 assert.equal(offered("[...Math.ma")[0], "max");
1532 assert.equal(complete("replTestGlobal?.answer.toFi")?.from, 23);
1533 assert.equal(offered("replTestGlobal?.answer.toFi", "tab"), "replTestGlobal?.answer.toFixed");
1534 assert.deepEqual(UNWRAP(complete("replTestGlobal.")).options.slice(0, 2), ["answer", "__defineGetter__"]);
1535 // names in scope do not, until asked for
1536 assert.equal(offered("x = proc"), "x = proc");
1537 assert.equal(offered("x = proc", "tab"), "x = process");
1538 ASSERT(UNWRAP(complete("awa")).options.includes("await"));
1539 ASSERT(UNWRAP(complete("globalThis.")).options.includes("Array"));
1540
1541 // only chains of names are evaluated
1542 assert.equal(complete("[]."), null);
1543 assert.equal(complete("'text'.le"), null);
1544 assert.equal(complete("f().process.ar"), null);
1545 assert.equal(complete("1 + "), null);
1546 assert.deepEqual(complete("replTestMissing.x"), { from: 16, options: [] });
1547 // nor is there anything to complete in text that is not code
1548 assert.equal(complete("'see process.ar"), null);
1549 assert.equal(complete("x // process.ar"), null);
1550 assert.deepEqual(offered("`${process.ar").slice(0, 3), ["arch", "argv", "argv0"]);
1551
1552 // names declared at the prompt are known
1553 await printed("class ReplTestClass {}");
1554 assert.equal(offered("new ReplTestCl", "tab"), "new ReplTestClass");
1555
1556 // a getter is not run to find out what it would return
1557 let ran = false;
1558 Object.assign(globalThis, {
1559 replTestGetter: {
1560 get value() {
1561 ran = true;
1562 return {};
1563 },
1564 },
1565 });
1566 assert.deepEqual(offered("replTestGetter.va"), ["value", "valueOf"]);
1567 assert.deepEqual(complete("replTestGetter.value."), { from: 21, options: [] });
1568 ASSERT(!ran);
1569 });
1570});
1571
1572import assert from "node:assert/strict";
1573import * as fs from "node:fs";
1574import * as os from "node:os";
1575import * as path from "node:path";
1576import * as util from "node:util";
1577import { describe, test } from "vitest";
1578import { ASSERT, UNWRAP } from "./assert.ts";
1579import * as repl from "./repl.ts";
1580import * as ansi from "./string/ansi.ts";
1581import * as testing from "./testing.ts";
lib/repl.ts created+1994
...@@ -0,0 +1,1994 @@
1/**
2 * add an interactive prompt to any long running program to perform debugging.
3 *
4 * ```ts
5 * const prompt = new repl.Repl({
6 * // customize the visual
7 * style: 'filled',
8 * title: 'My application',
9 *
10 * // can support any language for raw text input
11 * language: repl.javascript({ globals: { db } }),
12 *
13 * // slash-commands are exposed as a layer on top of the `language`
14 * commands: {
15 * // allows typing `/post .....`
16 * async post(args, node) {
17 * using step = node.start("waiting for the server");
18 * // ...
19 * },
20 * },
21 *
22 * // many more options to customize how it displays
23 * });
24 *
25 * import * as repl from "@clo/lib/repl";
26 * ```
27 *
28 * @module
29 */
30
31/** options for {@linkcode Repl} */
32export interface Options {
33 /**
34 * what the prompt evaluates when the line is not a command. `null`
35 * disables arbitrary code execution, leaving only commands.
36 * @default `javascript()`
37 */
38 language?: Language | null;
39 /**
40 * run by entering their name after a slash, as in `/name args`. a function
41 * is a command that is nothing more than its `execute`.
42 */
43 commands?: Record<string, Command | CommandExecute>;
44 /** @default a blue `> ` */
45 prompt?: string;
46 /** @default "plain" */
47 style?: Style;
48 /** named in the upper edge of a {@linkcode Style} that has one */
49 title?: string;
50 /** one line that is shown in place of the text while there is none */
51 placeholder?: string;
52 /**
53 * rows kept under the prompt, so that the first of what comes and goes
54 * there, like a unit of work or a menu, is shown without moving anything.
55 * @default 1
56 */
57 reserve?: number;
58 /** a file to remember entered lines in, across runs of the program */
59 history?: string;
60 /** layered over {@linkcode keymap}. `null` unbinds a key. */
61 keymap?: Record<string, Binding | null>;
62 /** @default `log` */
63 interface?: Host;
64}
65
66/**
67 * how the prompt is set apart from what is around it: not at all, by a line
68 * above and below, or by a background that takes the place of those lines.
69 */
70export type Style = "plain" | "lines" | "filled";
71
72export type CommandExecute = (this: Repl, args: string, node: progress.Node) => Promise<void> | void;
73export interface Command {
74 /**
75 * `args` is what follows the command name. `node` is the entered line as a
76 * unit of work, shown under the prompt until the command is done: what it
77 * logs and starts is kept with it.
78 */
79 execute: CommandExecute;
80 /** wait for `execute` to complete before giving control back to input */
81 block?: boolean;
82 /**
83 * what could stand in for the end of `args`, which is what follows the
84 * command name up to the cursor. `from` is an offset into it. this is
85 * called like {@linkcode Language.complete}: as text is typed, to bring
86 * up what is worth offering unasked, and with `asked` once tab is pressed
87 * or the menu is up.
88 */
89 complete?: (args: string, asked: boolean) => Completion | null;
90}
91
92/** the parts of a widget host that a {@linkcode Repl} uses */
93export type Host = Pick<
94 log.WidgetHost,
95 "startWidget" | "writeOutput" | "writeError" | "getDrawLock" | "delay" | "now"
96>;
97
98/**
99 * what a prompt speaks.
100 *
101 * a language that is only used to {@linkcode highlight} needs just
102 * `tokenizer` and `styles`.
103 */
104export interface Language {
105 tokenizer: Tokenizer;
106 /** an ANSI style for each {@link Token.kind|kind of token} that has one */
107 styles: Record<string, string>;
108 /**
109 * evaluate an entered line, passing results to `print`, which formats
110 * values the way `console.log` would. the prompt is hidden until the
111 * promise settles.
112 */
113 eval(code: string, print: (value: unknown) => void): Promise<void>;
114 /**
115 * whether pressing enter should continue `code` on a new line instead of
116 * evaluating it. `state` is the tokenizer's, at the end of `code`.
117 */
118 incomplete?(code: string, state: string): boolean;
119 /**
120 * everything that could stand in for `code` between `from` and `cursor`,
121 * which the editor narrows down to what the text there loosely matches.
122 * this is called as text is typed, to bring up a menu wherever there is
123 * something worth offering unasked, like the members of an object. once
124 * the menu is up, or when it is `asked` for by key, offer all there is.
125 * `kind` is that of the token which the cursor is in or right after. an
126 * answer that is promised is shown once it is there, unless a key was
127 * pressed since.
128 */
129 complete?(
130 code: string,
131 cursor: number,
132 asked: boolean,
133 kind: string,
134 ): Completion | null | Promise<Completion | null>;
135}
136
137export interface Completion {
138 from: number;
139 options: string[];
140}
141
142/**
143 * a lexer that can resume from any token boundary, which is what lets an
144 * {@linkcode Editor} relex only the tokens around an edit.
145 */
146export interface Tokenizer {
147 /** the state at the start of the text */
148 start: string;
149 /**
150 * lex the one token at `offset`, which is before the end of `text`.
151 * anything this needs to know about the text before `offset` must come
152 * from `state`, the state of the previous token. it may look ahead
153 * through the token after this one, but not further.
154 */
155 next(text: string, offset: number, state: string): Token;
156}
157
158export interface Token {
159 /** where the token ends. tokens cover the text without gaps. */
160 end: number;
161 /** names an entry in {@linkcode Language.styles} */
162 kind: string;
163 /** states are compared by value to find where an edit stops mattering */
164 state: string;
165}
166
167/** options for {@linkcode javascript} */
168export interface JavascriptOptions {
169 /**
170 * values for the evaluated code to refer to by name. these are defined on
171 * `globalThis`, as code is evaluated in the program's own global scope.
172 */
173 globals?: Record<string, unknown>;
174}
175
176/**
177 * javascript, which is what a {@linkcode Repl} speaks unless told otherwise.
178 * in node.js, code is evaluated through the V8 inspector the same way the
179 * Chrome DevTools console does it: top-level `await` works, `let` and `const`
180 * persist between lines and can be redeclared, and the members of an object
181 * are completed by evaluating it with side effects forbidden. other runtimes
182 * get a plain `eval`.
183 *
184 * ```ts
185 * // on its own, the language is enough to color source code with
186 * console.info(repl.highlight(repl.javascript(), "const x = `a${1}`;"));
187 * ```
188 */
189export function javascript({ globals = {} }: JavascriptOptions = {}): Language {
190 for (const [name, value] of Object.entries(globals)) {
191 Object.defineProperty(globalThis, name, {
192 value,
193 writable: true,
194 configurable: true,
195 });
196 }
197
198 // connected once there is code to look at, as coloring needs none of it
199 let connection: Inspector | null | undefined;
200 const inspector = () => connection === undefined ? connection = connect() : connection;
201 // what was last completed the members of. these are found once, when the
202 // dot is typed, and not again for each letter of the name after it.
203 let members: { of: string; names: string[] } | null = null;
204
205 return {
206 tokenizer: javascriptTokenizer,
207 styles: javascriptStyles,
208
209 incomplete(code, state) {
210 // a closing bracket without its opener is not fixed by more lines
211 if (state[0] === "!") return false;
212 const session = inspector();
213 if (!session) return state.length > 1;
214
215 // the parser is the judge, since the lexer can misread what a slash
216 // means. code that parses up to a brace it did not open wanted more.
217 const reply = session.ask("Runtime.evaluate", {
218 expression: `(async()=>{\n${code}\n})`,
219 objectGroup: "parse",
220 silent: true,
221 });
222 session.ask("Runtime.releaseObjectGroup", { objectGroup: "parse" });
223 const details = reply?.exceptionDetails;
224 if (details?.exception?.className !== "SyntaxError") return false;
225 // an unclosed comment is reported where it opens instead
226 return details.lineNumber === code.split(lineTerminator).length + 1
227 || state.at(-2) === "*";
228 },
229
230 async eval(code, print) {
231 members = null;
232 const session = inspector();
233 let value: unknown;
234 if (session) {
235 const reply = await session.evaluate({
236 expression: bridgeImports(code),
237 replMode: true,
238 awaitPromise: true,
239 objectGroup: "eval",
240 });
241 try {
242 if (reply.exceptionDetails) {
243 const error = session.take(reply.exceptionDetails.exception);
244 if (error instanceof Error && error.stack) {
245 // what is below the entered code is how it got evaluated, and
246 // the frame of the entered line itself has no source to show
247 const trace = error.stack
248 .replace(/\n at Session\.post \(node:inspector[^]*$/, "")
249 .replace(/\n at (?:async )?<anonymous>:\d+:\d+$/, "");
250 Reflect.set(error, "stack", trace);
251 }
252 throw error;
253 }
254 value = session.take(reply.result);
255 } finally {
256 session.ask("Runtime.releaseObjectGroup", { objectGroup: "eval" });
257 }
258 } else {
259 value = (0, eval)(code);
260 }
261
262 // a promise is a value like any other, and is not waited for. nothing
263 // else will handle its rejection though, and an unhandled one ends
264 // the process.
265 if (value instanceof Promise) value.catch(log.error);
266 if (value !== undefined) print(value);
267 },
268
269 complete(code, cursor, asked, kind) {
270 if (kind === "string" || kind === "comment" || kind === "regexp") return null;
271 const before = code.slice(Math.max(0, cursor - 256), cursor);
272 const [, chain = "", partial = ""] = UNWRAP(completable.exec(before));
273 const from = cursor - partial.length;
274 // a member of anything but a chain of names would need evaluating it
275 const owner = code.slice(0, from - chain.length).trimEnd();
276 if ((!chain && !partial) || /[^.]\.$/.test(owner)) return null;
277 // the names in scope are too many to bring up without being asked
278 if (!chain && !asked) return null;
279
280 let names: string[];
281 if (chain) {
282 const of = chain.replace(/\s*\??\.\s*$/, "");
283 if (members?.of !== of || partial === "") {
284 // getters may run, so this is only done where side effects are caught
285 const reply = inspector()?.ask("Runtime.evaluate", {
286 expression: `(${propertyNames})(${of})`,
287 throwOnSideEffect: true,
288 returnByValue: true,
289 silent: true,
290 timeout: 100,
291 });
292 members = { of, names: reply?.exceptionDetails ? [] : reply?.result?.value ?? [] };
293 }
294 names = members.names;
295 } else {
296 names = [
297 ...keywords,
298 ...literals,
299 ...propertyNames(globalThis),
300 ...inspector()?.ask("Runtime.globalLexicalScopeNames", {})?.names ?? [],
301 ].sort();
302 }
303
304 const options = new Set(names.filter((name) => identifier.test(name)));
305 return { from, options: [...options] };
306 },
307 };
308}
309
310/**
311 * what {@linkcode serve} and {@linkcode remote} talk over: either end of the
312 * channel between a node.js process and a child that it started with `"ipc"`
313 * among its `stdio`, as `fork` does. that is `process` in the child, and
314 * the `ChildProcess` in its parent. the messages are JSON under a key of
315 * their own, which leaves the channel free to carry others.
316 */
317export interface Channel {
318 send?(message: unknown, callback: (error: Error | null) => void): unknown;
319 on(event: "message", listener: (message: unknown) => void): unknown;
320 on(event: "disconnect", listener: () => void): unknown;
321 off(event: "message", listener: (message: unknown) => void): unknown;
322 off(event: "disconnect", listener: () => void): unknown;
323}
324
325/**
326 * let the process at the other end of `channel` evaluate and complete code
327 * in this one, by having its prompt speak {@linkcode remote}. values are
328 * formatted here, where they are, and arrive there as text.
329 *
330 * ```ts
331 * // in a server that a control process started with `fork`
332 * repl.serve(repl.javascript({ globals: { server } }), process);
333 * ```
334 */
335export function serve(language: Language, channel: Channel): ts.Dispose {
336 const listener = (message: unknown) => {
337 const request = unpack<Request>(message);
338 if (!request) return;
339 const { id } = request;
340
341 // an answer that cannot be sent has nobody left to hear of it
342 const reply = (reply: Reply) => post(channel, reply, () => {});
343
344 if ("complete" in request) {
345 Promise.resolve()
346 .then(() => language.complete?.(...request.complete) ?? null)
347 .catch(() => null)
348 .then((found) => reply({ id, found }));
349 return;
350 }
351 language.eval(request.eval, (value) => reply({ id, print: inspect(value) }))
352 .then(() => null, report)
353 .then((failed) => reply({ id, failed }));
354 };
355 channel.on("message", listener);
356 return ts.defer(() => void channel.off("message", listener));
357}
358
359/**
360 * the language of the process at the other end of `channel`, which
361 * {@link serve|serves} it. code is colored by `language`, and judged to be
362 * complete by it, in this process. evaluating and completing it is theirs.
363 *
364 * ```ts
365 * const server = child_process.fork("server.ts");
366 * new repl.Repl({ language: repl.remote(server) });
367 * ```
368 */
369export function remote(channel: Channel, language: Language = javascript()): Language {
370 let requests = 0;
371 /** send `request`, and pass on what comes back for it until `receive` says that was all */
372 const ask = (request: object, receive: (reply: Reply) => boolean) =>
373 new Promise<void>((resolve, reject) => {
374 const id = requests += 1;
375 const settle = (settled: () => void) => {
376 channel.off("message", onMessage);
377 channel.off("disconnect", onDisconnect);
378 settled();
379 };
380 const onMessage = (message: unknown) => {
381 const reply = unpack<Reply>(message);
382 if (reply?.id === id && receive(reply)) settle(resolve);
383 };
384 const onDisconnect = () => settle(() => reject(new Error("the process that was asked is gone")));
385
386 channel.on("message", onMessage);
387 channel.on("disconnect", onDisconnect);
388 post(channel, { id, ...request }, (error) => {
389 if (error) onDisconnect();
390 });
391 });
392
393 return {
394 ...language,
395 eval: (code, print) =>
396 ask({ eval: code }, (reply) => {
397 if ("print" in reply) print(formatted(reply.print));
398 if ("failed" in reply && reply.failed !== null) print(formatted(reply.failed));
399 return "failed" in reply;
400 // that the process is gone is not for a trace into this module to say
401 }).catch((error: Error) => print(formatted(ansi.style(ansi.fgRed, error.message)))),
402 async complete(...complete) {
403 let found: Completion | null = null;
404 await ask({ complete }, (reply) => {
405 if ("found" in reply) found = reply.found;
406 return "found" in reply;
407 }).catch(() => {});
408 return found;
409 },
410 };
411}
412
413type Request =
414 | { id: number; eval: string }
415 | { id: number; complete: Parameters<NonNullable<Language["complete"]>> };
416type Reply =
417 | { id: number; print: string }
418 // how evaluating ended: with nothing, or with a report of what was thrown
419 | { id: number; failed: string | null }
420 | { id: number; found: Completion | null };
421
422/** the key that messages of this module are under */
423const wire = "@clo/lib/repl";
424
425/** send a message, to hear through `sent` whether it could be. node.js would otherwise throw from nowhere. */
426function post(channel: Channel, payload: object, sent: (error: Error | null) => void) {
427 ASSERT(channel.send, "this process was not started with a channel to its parent");
428 channel.send({ [wire]: payload }, sent);
429}
430
431function unpack<T>(message: unknown): T | undefined {
432 return typeof message === "object" && message !== null && wire in message
433 ? (message as Record<string, T>)[wire]
434 : undefined;
435}
436
437/** a value that formats as `text`, for what was already formatted elsewhere */
438function formatted(text: string): unknown {
439 return { [Symbol.for("nodejs.util.inspect.custom")]: () => text, toString: () => text };
440}
441
442/** lex all of `text` */
443export function tokenize(tokenizer: Tokenizer, text: string): Token[] {
444 const tokens: Token[] = [];
445 retokenize(tokenizer, tokens, text, 0, 0, text.length);
446 return tokens;
447}
448
449/**
450 * update `tokens` in place after an edit, where `text` is the result of
451 * replacing `removed` characters at `start` with `inserted` characters.
452 * lexing starts just before the edit, and stops at the first token that ends
453 * where a token used to end with the same state, since everything after it
454 * would lex the same as before.
455 */
456export function retokenize(
457 tokenizer: Tokenizer,
458 tokens: Token[],
459 text: string,
460 start: number,
461 removed: number,
462 inserted: number,
463): void {
464 const shift = inserted - removed;
465 // one token before the first that touches the edit, as it may have looked
466 // ahead into it
467 const first = Math.max(0, tokenIndex(tokens, start - 1) - 1);
468 let offset = tokens[first - 1]?.end ?? 0;
469 let state = tokens[first - 1]?.state ?? tokenizer.start;
470
471 const fresh: Token[] = [];
472 let old = first;
473 let kept = tokens.length;
474 while (offset < text.length) {
475 const token = tokenizer.next(text, offset, state);
476 ASSERT(token.end > offset && token.end <= text.length, "Tokenizer did not advance");
477 fresh.push(token);
478 ({ end: offset, state } = token);
479
480 if (offset < start + inserted) continue;
481 while (old < tokens.length && UNWRAP(tokens[old]).end + shift < offset) old += 1;
482 const before = tokens[old];
483 if (before && before.end + shift === offset && before.state === state) {
484 kept = old + 1;
485 break;
486 }
487 }
488
489 const rest = tokens.splice(kept);
490 tokens.length = first;
491 for (const token of fresh) tokens.push(token);
492 for (const token of rest) {
493 token.end += shift;
494 tokens.push(token);
495 }
496}
497
498/** style `code` with ANSI colors */
499export function highlight(
500 language: Pick<Language, "tokenizer" | "styles">,
501 code: string,
502): string {
503 return paint(code, tokenize(language.tokenizer, code), language.styles).join("\n");
504}
505
506/** see {@linkcode Editor.menu} */
507export interface Menu {
508 /** an option stands in for the text from here to the cursor */
509 from: number;
510 options: string[];
511 /** index of the highlighted option */
512 selected: number;
513}
514
515/**
516 * a line editor without a terminal: methods are the editing actions, and the
517 * public fields are everything needed to draw it. the methods that take no
518 * arguments are the ones that keys can be {@link Binding|bound} to.
519 *
520 * the methods are meant to be called through {@linkcode run}, one call per
521 * key press, since some act differently in a row: kills collect into one
522 * yank, vertical movement remembers its column, typing undoes as a unit. it
523 * is also `run` that brings up completions for what was typed.
524 */
525export class Editor {
526 text = "";
527 /** an offset into `text`, never inside a grapheme */
528 cursor = 0;
529 /** `text` as lexed by the tokenizer, kept current through every edit */
530 tokens: Token[] = [];
531 /** entered lines, oldest first */
532 history: string[];
533 /** completions on offer, narrowed down to what the typed text matches */
534 menu: Menu | null = null;
535 /** the query of a history search, while searching */
536 search: { query: string; failed: boolean } | null = null;
537
538 #syntax: Pick<Language, "tokenizer" | "complete">;
539 #undo: Edit[] = [];
540 #redo: Edit[] = [];
541 #kills: string[] = [];
542 #yanked = { from: 0, index: 0 };
543 /** index of the history entry on display, or null for the line being written */
544 #recalled: number | null = null;
545 #draft = "";
546 #searchAt = 0;
547 #searchOrigin = "";
548 #goal = 0;
549 /** what kind of action this is, and what the one before it was */
550 #mark = "";
551 #previous = "";
552 /** completions that the latest key press is still waiting for */
553 #late: Promise<void> | undefined;
554
555 constructor(
556 syntax: Pick<Language, "tokenizer" | "complete">,
557 history: string[] = [],
558 ) {
559 this.#syntax = syntax;
560 this.history = history;
561 }
562
563 /**
564 * perform the actions of one key press. completions that the language
565 * has yet to answer with are on the {@linkcode menu} once what this
566 * returns has resolved.
567 */
568 run(action: () => void): Promise<void> | undefined {
569 this.#previous = this.#mark;
570 this.#mark = "";
571 this.#late = undefined;
572 const asked = this.menu !== null;
573 action();
574 // any other action leaves the search with what it found
575 if (this.#mark !== "search") this.search = null;
576 // typing narrows down the completions on offer, or brings some up
577 if (this.#mark === "type" || this.#mark === "erase") this.#suggest(asked);
578 else if (this.#mark !== "menu") this.menu = null;
579 return this.#late;
580 }
581
582 /**
583 * replace the text from `from` to `to`, leaving the cursor after what was
584 * inserted, unless told where. every edit passes through here.
585 */
586 replace(from: number, to: number, insert: string, cursor: number = from + insert.length): void {
587 const removed = this.text.slice(from, to);
588 if (removed !== insert) {
589 this.#undo.push({ from, removed, inserted: insert, before: this.cursor, after: cursor });
590 this.#redo = [];
591 this.#apply(from, to, insert);
592 }
593 this.cursor = cursor;
594 }
595
596 /** insert at the cursor as if typed, which is how unbound keys act */
597 type(text: string): void {
598 if (text === "") return;
599 if (this.search) {
600 this.search.query += text;
601 this.#find(this.#searchAt, -1);
602 return;
603 }
604
605 const last = this.#undo.at(-1);
606 const continues = this.#previous === "type" && last?.removed === ""
607 && last.from + last.inserted.length === this.cursor;
608 this.replace(this.cursor, this.cursor, text);
609 if (continues) {
610 this.#undo.pop();
611 last.inserted += text;
612 last.after = this.cursor;
613 }
614 this.#mark = "type";
615
616 // a closing bracket on a blank line belongs one level out
617 const start = lineStart(this.text, this.cursor);
618 const before = this.text.slice(start, this.cursor - 1);
619 if (/^[)\]}]$/.test(text) && before !== "" && before.trim() === "") {
620 const end = this.cursor - Math.min(indent.length, before.length);
621 this.replace(end - 1, this.cursor - 1, "", end);
622 }
623 }
624
625 /** end the line, returning it. it joins the history unless blank or a repeat. */
626 accept(): string {
627 const { text } = this;
628 if (text.trim() && text !== this.history.at(-1)) this.history.push(text);
629 this.clear();
630 return text;
631 }
632
633 /** throw away the line */
634 clear(): void {
635 this.#apply(0, this.text.length, "");
636 this.cursor = 0;
637 this.#undo = [];
638 this.#redo = [];
639 this.search = null;
640 this.menu = null;
641 }
642
643 moveLeft(): void {
644 this.cursor = this.#grapheme(-1);
645 }
646 moveRight(): void {
647 this.cursor = this.#grapheme(1);
648 }
649 moveWordLeft(): void {
650 this.cursor = this.#word(-1, isWord);
651 }
652 moveWordRight(): void {
653 this.cursor = this.#word(1, isWord);
654 }
655 moveLineStart(): void {
656 this.cursor = lineStart(this.text, this.cursor);
657 }
658 moveLineEnd(): void {
659 this.cursor = lineEnd(this.text, this.cursor);
660 }
661 moveStart(): void {
662 this.cursor = 0;
663 }
664 moveEnd(): void {
665 this.cursor = this.text.length;
666 }
667 /**
668 * to the option above in the {@linkcode menu}, else to the line above, or
669 * from the first line, to the previous history entry
670 */
671 moveUp(): void {
672 if (this.menu) return this.#select(-1);
673 const start = lineStart(this.text, this.cursor);
674 if (start === 0) return this.historyPrevious();
675 this.#vertical(start, lineStart(this.text, start - 1));
676 }
677 /** the opposite of {@linkcode moveUp} */
678 moveDown(): void {
679 if (this.menu) return this.#select(1);
680 const end = lineEnd(this.text, this.cursor);
681 if (end === this.text.length) return this.historyNext();
682 this.#vertical(lineStart(this.text, this.cursor), end + 1);
683 }
684
685 deleteLeft(): void {
686 if (this.search) {
687 this.search.query = this.search.query.slice(0, this.#graphemeIn(this.search.query, -1));
688 this.#find(this.history.length, -1);
689 return;
690 }
691 this.replace(this.#grapheme(-1), this.cursor, "");
692 this.#mark = "erase";
693 }
694 deleteRight(): void {
695 this.replace(this.cursor, this.#grapheme(1), "");
696 }
697 killWordLeft(): void {
698 this.#kill(this.#word(-1, isWord), this.cursor);
699 }
700 killWordRight(): void {
701 this.#kill(this.cursor, this.#word(1, isWord));
702 }
703 /** kill back to whitespace, which takes a whole path where a word is one segment */
704 killToSpaceLeft(): void {
705 this.#kill(this.#word(-1, (char) => /\S/.test(char)), this.cursor);
706 }
707 killLineStart(): void {
708 this.#kill(lineStart(this.text, this.cursor), this.cursor);
709 }
710 killLineEnd(): void {
711 this.#kill(this.cursor, lineEnd(this.text, this.cursor));
712 }
713 /** insert what was last killed */
714 yank(): void {
715 const index = this.#kills.length - 1;
716 if (index < 0) return;
717 this.#yanked = { from: this.cursor, index };
718 this.replace(this.cursor, this.cursor, UNWRAP(this.#kills[index]));
719 this.#mark = "yank";
720 }
721 /** following a yank, swap it for the kill before */
722 yankPop(): void {
723 if (this.#previous !== "yank") return;
724 const { from } = this.#yanked;
725 const index = (this.#yanked.index || this.#kills.length) - 1;
726 this.#yanked = { from, index };
727 this.replace(from, this.cursor, UNWRAP(this.#kills[index]));
728 this.#mark = "yank";
729 }
730
731 undo(): void {
732 const edit = this.#undo.pop();
733 if (!edit) return;
734 this.#apply(edit.from, edit.from + edit.inserted.length, edit.removed);
735 this.cursor = edit.before;
736 this.#redo.push(edit);
737 }
738 redo(): void {
739 const edit = this.#redo.pop();
740 if (!edit) return;
741 this.#apply(edit.from, edit.from + edit.removed.length, edit.inserted);
742 this.cursor = edit.after;
743 this.#undo.push(edit);
744 }
745
746 /** break the line, indented like the one it leaves, and deeper after an open bracket */
747 newline(): void {
748 const before = this.text.slice(lineStart(this.text, this.cursor), this.cursor);
749 const deeper = /[([{]\s*$/.test(before) ? indent : "";
750 this.replace(this.cursor, this.cursor, "\n" + UNWRAP(/^[ \t]*/.exec(before))[0] + deeper);
751 }
752
753 /**
754 * take the highlighted completion. without a {@linkcode menu}, ask for
755 * one, taking the completion right away if there is only the one. on a
756 * blank line, this indents instead.
757 */
758 complete(): void {
759 const { menu, text, cursor } = this;
760 if (menu) return this.replace(menu.from, cursor, UNWRAP(menu.options[menu.selected]));
761 if (text.slice(lineStart(text, cursor), cursor).trim() === "") {
762 return this.replace(cursor, cursor, indent);
763 }
764
765 this.#suggest(true, true);
766 this.#mark = "menu";
767 }
768
769 /** close the menu, or abandon a history search for the text from before it */
770 cancel(): void {
771 if (this.search) this.replace(0, this.text.length, this.#searchOrigin);
772 }
773
774 historyPrevious(): void {
775 this.#recall(-1, () => true);
776 }
777 historyNext(): void {
778 this.#recall(1, () => true);
779 }
780 /** the previous history entry that starts with the text before the cursor */
781 historyPrefixPrevious(): void {
782 const prefix = this.text.slice(0, this.cursor);
783 this.#recall(-1, (entry) => entry.startsWith(prefix), this.cursor);
784 }
785 historyPrefixNext(): void {
786 const prefix = this.text.slice(0, this.cursor);
787 this.#recall(1, (entry) => entry.startsWith(prefix), this.cursor);
788 }
789 /**
790 * start searching the history for what is typed next. while searching,
791 * step to the next older match.
792 */
793 searchBackward(): void {
794 this.#search(-1);
795 }
796 /** like {@linkcode searchBackward}, towards newer entries */
797 searchForward(): void {
798 this.#search(1);
799 }
800
801 #apply(from: number, to: number, insert: string) {
802 this.text = this.text.slice(0, from) + insert + this.text.slice(to);
803 retokenize(this.#syntax.tokenizer, this.tokens, this.text, from, to - from, insert.length);
804 // changed text is a new line being written, wherever it came from
805 this.#recalled = null;
806 }
807
808 /** the edge of the grapheme before or after the cursor, crossing lines */
809 #grapheme(step: -1 | 1): number {
810 const { text, cursor } = this;
811 const start = lineStart(text, cursor);
812 const end = lineEnd(text, cursor);
813 if (cursor === (step < 0 ? start : end)) {
814 return Math.max(0, Math.min(text.length, cursor + step));
815 }
816 return step < 0
817 ? start + this.#graphemeIn(text.slice(start, cursor), -1)
818 : cursor + this.#graphemeIn(text.slice(cursor, end), 1);
819 }
820
821 /** the offset in `text` of its last grapheme (-1), or of the end of its first (1) */
822 #graphemeIn(text: string, step: -1 | 1): number {
823 if (text === "") return 0;
824 segmenter ??= new Intl.Segmenter();
825 const segments = segmenter.segment(text);
826 return step < 0
827 ? UNWRAP(segments.containing(text.length - 1)).index
828 : UNWRAP(segments.containing(0)).segment.length;
829 }
830
831 /** past the run of `inWord` characters in a direction, and whatever came before it */
832 #word(step: -1 | 1, inWord: (char: string) => boolean): number {
833 const { text } = this;
834 let i = this.cursor;
835 const next = () => text[step < 0 ? i - 1 : i];
836 for (let char = next(); char !== undefined && !inWord(char); char = next()) i += step;
837 for (let char = next(); char !== undefined && inWord(char); char = next()) i += step;
838 // a grapheme can be made of both kinds of character
839 return snap(text, i, step);
840 }
841
842 /** move from the line at `from` to the line at `to`, staying in the column */
843 #vertical(from: number, to: number) {
844 if (this.#previous !== "vertical") {
845 this.#goal = cells(this.text, from, this.cursor)
846 .reduce((width, cell) => width + cell.width, 0);
847 }
848 this.#mark = "vertical";
849
850 let width = 0;
851 this.cursor = lineEnd(this.text, to);
852 for (const cell of cells(this.text, to, this.cursor)) {
853 if (width >= this.#goal) {
854 this.cursor = cell.offset;
855 break;
856 }
857 width += cell.width;
858 }
859 }
860
861 #kill(from: number, to: number) {
862 const killed = this.text.slice(from, to);
863 if (killed === "") return;
864 // kills in a row are one kill, in the order of the text
865 if (this.#previous !== "kill") this.#kills.push(killed);
866 else if (to === this.cursor) this.#kills.push(killed + this.#kills.pop());
867 else this.#kills.push(this.#kills.pop() + killed);
868 this.replace(from, to, "");
869 this.#mark = "kill";
870 }
871
872 /** bring the {@linkcode menu} up to date with the text */
873 /** ask the language what to offer, to `take` it right away if it is all there is */
874 #suggest(asked: boolean, take = false) {
875 const { text, cursor } = this;
876 const kind = this.tokens[tokenIndex(this.tokens, cursor - 1)]?.kind ?? "";
877 const found = this.#syntax.complete?.(text, cursor, asked, kind) ?? null;
878 if (!(found instanceof Promise)) return this.#offer(found, take);
879
880 // until the answer is there, what was on offer is narrowed down as it is
881 this.#offer(this.menu && this.menu.from <= cursor ? this.menu : null, false);
882 const late = found.catch(() => null).then((answer) => {
883 // a key that was pressed since has asked anew, or wants no menu
884 if (this.#late === late) this.#offer(answer, take);
885 });
886 this.#late = late;
887 }
888
889 #offer(found: Completion | null, take: boolean) {
890 const { text, cursor } = this;
891 const typed = found ? text.slice(found.from, cursor) : "";
892 const options = found ? narrow(found.options, typed) : [];
893 this.menu = null;
894 if (found && take && options.length === 1) {
895 return this.replace(found.from, cursor, UNWRAP(options[0]));
896 }
897 // an only option that is already typed out is nothing to offer
898 const offers = options.length > 1 || (options.length === 1 && options[0] !== typed);
899 if (found && offers) this.menu = { from: found.from, options, selected: 0 };
900 }
901
902 #select(step: -1 | 1) {
903 const menu = UNWRAP(this.menu);
904 const count = menu.options.length;
905 menu.selected = (menu.selected + step + count) % count;
906 this.#mark = "menu";
907 }
908
909 /**
910 * show the nearest history entry in a direction that `matches`. the line
911 * being written sits after the newest entry, to come back to.
912 */
913 #recall(step: -1 | 1, matches: (entry: string) => boolean, cursor?: number) {
914 const { history } = this;
915 let at = this.#recalled ?? history.length;
916 do at += step; while (at >= 0 && at < history.length && !matches(UNWRAP(history[at])));
917 if (at < 0 || (at >= history.length && this.#recalled === null)) return;
918
919 if (this.#recalled === null) this.#draft = this.text;
920 const entry = history[at] ?? this.#draft;
921 this.replace(0, this.text.length, entry, snap(entry, cursor ?? entry.length, -1));
922 this.#recalled = at < history.length ? at : null;
923 }
924
925 #search(step: -1 | 1) {
926 if (this.search) return this.#find(this.#searchAt + step, step);
927 this.search = { query: "", failed: false };
928 this.#searchOrigin = this.text;
929 this.#searchAt = this.history.length;
930 this.#mark = "search";
931 }
932
933 /**
934 * show the first history entry containing the query, looking from `at`.
935 * without a query, that is the text from before the search.
936 */
937 #find(at: number, step: -1 | 1) {
938 const { history } = this;
939 const search = UNWRAP(this.search);
940 const { query } = search;
941 this.#mark = "search";
942
943 at = query === "" ? history.length : Math.min(at, history.length - 1);
944 while (at >= 0 && at < history.length && !UNWRAP(history[at]).includes(query)) at += step;
945 search.failed = at < 0 || (at >= history.length && query !== "");
946 if (search.failed) return;
947
948 this.#searchAt = at;
949 const entry = history[at] ?? this.#searchOrigin;
950 const found = query ? entry.indexOf(query) + query.length : entry.length;
951 this.replace(0, this.text.length, entry, found);
952 }
953}
954
955interface Edit {
956 from: number;
957 removed: string;
958 inserted: string;
959 /** where the cursor was, and where the edit left it */
960 before: number;
961 after: number;
962}
963
964/**
965 * what a key does: the name of an {@linkcode Editor} method that takes no
966 * arguments, or a function. functions run within {@linkcode Editor.run}.
967 */
968export type Binding = EditorAction | ((editor: Editor, repl: Repl) => void);
969type EditorAction = {
970 [K in keyof Editor]: Editor[K] extends () => void ? K : never;
971}[keyof Editor];
972
973/**
974 * the default key bindings, by {@link log.WidgetInput.key|key name}. these
975 * follow readline, with the arrow and modifier keys of graphical text fields
976 * added. keys without a binding type their text.
977 *
978 * while completions are on offer, the up and down keys move among them, and
979 * tab or enter takes the highlighted one, which escape declines.
980 */
981export const keymap: Record<string, Binding> = {
982 "enter": (_, repl) => repl.submit(),
983 "shift+enter": "newline",
984 "alt+enter": "newline",
985 "ctrl+j": "newline",
986 "tab": "complete",
987 "escape": "cancel",
988 "ctrl+g": "cancel",
989 "ctrl+c": interrupt,
990 "ctrl+d": (editor, repl) => editor.text ? editor.deleteRight() : repl.close(),
991 "ctrl+z": suspend,
992 "ctrl+l": (_, repl) => repl.clearScreen(),
993
994 "left": "moveLeft",
995 "ctrl+b": "moveLeft",
996 "right": "moveRight",
997 "ctrl+f": "moveRight",
998 "ctrl+left": "moveWordLeft",
999 "alt+left": "moveWordLeft",
1000 "alt+b": "moveWordLeft",
1001 "ctrl+right": "moveWordRight",
1002 "alt+right": "moveWordRight",
1003 "alt+f": "moveWordRight",
1004 "home": "moveLineStart",
1005 "ctrl+a": "moveLineStart",
1006 "end": "moveLineEnd",
1007 "ctrl+e": "moveLineEnd",
1008 "ctrl+home": "moveStart",
1009 "ctrl+end": "moveEnd",
1010 "up": "moveUp",
1011 "ctrl+p": "moveUp",
1012 "down": "moveDown",
1013 "ctrl+n": "moveDown",
1014
1015 "backspace": "deleteLeft",
1016 "shift+backspace": "deleteLeft",
1017 "ctrl+h": "deleteLeft",
1018 "delete": "deleteRight",
1019 "alt+backspace": "killWordLeft",
1020 "ctrl+backspace": "killWordLeft",
1021 "alt+d": "killWordRight",
1022 "ctrl+delete": "killWordRight",
1023 "ctrl+w": "killToSpaceLeft",
1024 "ctrl+u": "killLineStart",
1025 "ctrl+k": "killLineEnd",
1026 "ctrl+y": "yank",
1027 "alt+y": "yankPop",
1028 "ctrl+_": "undo",
1029 "ctrl+/": "undo",
1030 "alt+/": "redo",
1031 "ctrl+shift+z": "redo",
1032
1033 "pageup": "historyPrefixPrevious",
1034 "pagedown": "historyPrefixNext",
1035 "ctrl+r": "searchBackward",
1036 "ctrl+s": "searchForward",
1037};
1038
1039function interrupt(editor: Editor, repl: Repl) {
1040 if (editor.text || editor.search) editor.clear();
1041 else repl.interrupt();
1042}
1043
1044function suspend(_: Editor, repl: Repl) {
1045 repl.suspend();
1046}
1047
1048/** what to draw an {@linkcode Editor} into */
1049export interface View {
1050 /** cells per row */
1051 width: number;
1052 /** rows at most. taller text scrolls to keep the cursor among them. */
1053 height: number;
1054 prompt: string;
1055 styles: Record<string, string>;
1056 style?: Style | undefined;
1057 title?: string | undefined;
1058 placeholder?: string | undefined;
1059 /** rows under the prompt that are left blank when nothing is drawn there */
1060 reserve?: number | undefined;
1061 /** the `scroll` of the frame before, to scroll no further than needed */
1062 scroll?: number;
1063}
1064
1065export interface Frame extends log.WidgetFrame {
1066 cursor: { line: number; column: number };
1067 /** index of the first row of text that is in view */
1068 scroll: number;
1069}
1070
1071/**
1072 * draw an {@linkcode Editor}: its text behind the prompt, soft wrapped and
1073 * styled by its tokens, with its completions in a box underneath, which is
1074 * where rows can come and go without the text moving. a
1075 * {@linkcode Style} other than `"plain"` adds a row above and below.
1076 */
1077export function formatAnsi(editor: Editor, view: View): Frame {
1078 const { text, cursor, search, menu } = editor;
1079 const { width, style = "plain" } = view;
1080 const gutter = ansi.widthInTerminal(view.prompt);
1081 const columns = Math.max(1, width - gutter);
1082 const rows = layout(text, columns);
1083 const cursorRow = rows.findLastIndex((row) => row.from <= cursor);
1084 const rowStart = UNWRAP(rows[cursorRow]).from;
1085 /** the cell that an offset on the cursor's row is drawn in */
1086 const column = (offset: number) => cells(text, rowStart, offset).reduce((width, cell) => width + cell.width, gutter);
1087
1088 const framed = style !== "plain";
1089 /** as many cells of the row above the text, which is also the one below */
1090 const edge = (cells: number) => style === "lines" ? ansi.style(ansi.dim, "─".repeat(cells)) : " ".repeat(cells);
1091 /** a row of the prompt, on the background if the style has one */
1092 const backed = (line: string) =>
1093 style !== "filled"
1094 ? line
1095 : filled + line.replaceAll(ansi.reset, ansi.reset + filled)
1096 + " ".repeat(Math.max(0, width - ansi.widthInTerminal(line))) + ansi.reset;
1097
1098 let top = edge(width);
1099 const titleRoom = width - (style === "lines" ? 6 : gutter);
1100 if (view.title && titleRoom > 0) {
1101 const title = ansi.trimForTerminal(view.title, titleRoom);
1102 top = style === "lines"
1103 ? ansi.style(ansi.dim, "─┤") + " " + title + " "
1104 + ansi.style(ansi.dim, "├" + "─".repeat(titleRoom + 1 - ansi.widthInTerminal(title)))
1105 : " ".repeat(gutter) + ansi.style(ansi.bold, title);
1106 }
1107 const above = framed ? [backed(top)] : [];
1108
1109 // the text gets its first row before the menu gets any. the box of the
1110 // menu starts on the row under the text, as if it hung from it.
1111 const taken = (framed ? 2 : 0) + (search ? 1 : 0);
1112 const spare = Math.max(0, view.height - taken - 1) + (framed ? 1 : 0);
1113 const box = menu && spare > 0
1114 ? formatMenu(menu, text.slice(menu.from, cursor), Math.min(menuRows, spare))
1115 : { lines: [], width: 0 };
1116 // lined up with what is being completed, as far as the box fits there
1117 const left = menu ? Math.max(0, Math.min(column(Math.max(rowStart, menu.from)) - 1, width - box.width)) : 0;
1118 const below = box.lines.map((line) => " ".repeat(left) + line);
1119 if (framed) {
1120 const [first] = box.lines;
1121 below[0] = backed(first === undefined ? edge(width) : edge(left) + first + edge(width - left - box.width));
1122 }
1123 if (search) {
1124 below.push(
1125 ansi.style(ansi.dim, "search:") + " " + search.query.replace(/\p{Cc}/gu, " ")
1126 + (search.failed ? " " + ansi.style(ansi.fgRed, "no match") : ""),
1127 );
1128 }
1129 // the text keeps a row before any is left blank
1130 const kept = below.length - (framed ? 1 : 0);
1131 const blank = Math.min((view.reserve ?? 0) - kept, view.height - above.length - below.length - 1);
1132 for (let i = 0; i < blank; i += 1) below.push("");
1133
1134 const height = Math.max(1, view.height - above.length - below.length);
1135 const last = Math.max(0, rows.length - height);
1136 const scroll = Math.min(
1137 last,
1138 Math.max(cursorRow - height + 1, Math.min(view.scroll ?? 0, cursorRow)),
1139 );
1140 const shown = rows.slice(scroll, scroll + height);
1141
1142 const lines = shown.map((row, i) => {
1143 const prefix = i === 0 && scroll > 0
1144 ? margin(gutter, "↑")
1145 : i === shown.length - 1 && scroll < last
1146 ? margin(gutter, "↓")
1147 : row.from === 0
1148 ? view.prompt
1149 : margin(gutter, row.head ? "…" : "");
1150 const body = text === "" && view.placeholder
1151 ? ansi.style(ansi.dim, ansi.trimForTerminal(view.placeholder, columns))
1152 : paintLine(text, editor.tokens, view.styles, row.from, row.to);
1153 return backed(prefix + body);
1154 });
1155
1156 return {
1157 text: [...above, ...lines, ...below].join("\n"),
1158 cursor: { line: above.length + cursorRow - scroll, column: column(cursor) },
1159 scroll,
1160 };
1161}
1162
1163/**
1164 * a prompt at the end of the terminal, which is live from construction until
1165 * {@linkcode close}. see the module documentation.
1166 *
1167 * when the terminal is not interactive, nothing is shown or read.
1168 */
1169export class Repl implements log.WidgetOptions {
1170 /** the line being written */
1171 readonly editor: Editor;
1172 /** looked up as lines are entered, so commands can be added at any time */
1173 readonly commands: Record<string, Command | CommandExecute>;
1174 /** work started on this is displayed under the prompt */
1175 readonly progress: progress.Ref;
1176
1177 #host: Host;
1178 #widget: log.WidgetInstance | null;
1179 #language: Language | null;
1180 #syntax: Syntax;
1181 #keymap: Record<string, Binding | null>;
1182 #look: Pick<View, "prompt" | "styles" | "style" | "title" | "placeholder">;
1183 #reserve: number;
1184 #historyFile: string | undefined;
1185 /** the widget that `progress.ts` draws, which is nested into this one */
1186 #status: log.WidgetOptions | null = null;
1187 #detachStatus: ts.Dispose;
1188 #scroll = 0;
1189 /** how many things the prompt is hidden for */
1190 #blocking = 0;
1191 /** keys pressed while the prompt was hidden */
1192 #typeahead: log.WidgetInput[] = [];
1193
1194 constructor(options: Options = {}) {
1195 const { language = javascript(), commands = {} } = options;
1196 const host: Host = options.interface ?? log;
1197 this.commands = commands;
1198 this.#host = host;
1199 this.#language = language;
1200 this.#syntax = withCommands(language, commands);
1201 this.#keymap = { ...keymap, ...options.keymap };
1202 this.#look = {
1203 prompt: options.prompt ?? ansi.style(ansi.fgBlue, ">") + " ",
1204 styles: this.#syntax.styles,
1205 style: options.style,
1206 title: options.title,
1207 placeholder: options.placeholder,
1208 };
1209 this.#reserve = options.reserve ?? 1;
1210 this.#historyFile = options.history;
1211 this.editor = new Editor(
1212 this.#syntax,
1213 options.history ? readHistory(options.history) : [],
1214 );
1215
1216 const root = new progress.Root(host);
1217 this.progress = root;
1218 this.#detachStatus = progress.attachToScreen(root, {
1219 writeOutput: (text) => host.writeOutput(text),
1220 startWidget: (status) => {
1221 const widget = this.#widget;
1222 if (!widget) return null;
1223 this.#status = status;
1224 return {
1225 options: status,
1226 get fps() {
1227 return widget.fps;
1228 },
1229 set fps(fps) {
1230 widget.fps = fps;
1231 },
1232 redraw: widget.redraw,
1233 stop: () => {
1234 this.#status = null;
1235 widget.fps = null;
1236 widget.redraw();
1237 },
1238 [Symbol.dispose]() {
1239 this.stop();
1240 },
1241 };
1242 },
1243 });
1244 this.#widget = host.startWidget(this);
1245 }
1246
1247 /** @internal called by the widget host */
1248 format(ctx: log.WidgetFormatContext): log.WidgetFrame {
1249 const drawn = this.#status?.format(ctx) ?? "";
1250 const status = typeof drawn === "string" ? drawn : drawn.text;
1251 // without the keyboard, a prompt would only be in the way
1252 if (!ctx.focused || this.#blocking > 0) return { text: status };
1253
1254 const frame = formatAnsi(this.editor, {
1255 ...this.#look,
1256 // work that is displayed is in the rows that are kept for it
1257 reserve: this.#reserve - (status ? string.countNewlines(status) + 1 : 0),
1258 // the host keeps the last column and the last row of the terminal clear
1259 width: ctx.width - 1,
1260 height: ctx.height - 1,
1261 scroll: this.#scroll,
1262 });
1263 this.#scroll = frame.scroll;
1264 // when there are too few rows for both, it is the prompt that is needed
1265 const room = ctx.height - 2 - string.countNewlines(frame.text);
1266 const shown = status ? status.split("\n").slice(0, Math.max(0, room)) : [];
1267 return { text: [frame.text, ...shown].join("\n"), cursor: frame.cursor };
1268 }
1269
1270 /** @internal called by the widget host */
1271 input(event: log.WidgetInput): void {
1272 const binding = this.#keymap[event.key];
1273 if (this.#blocking > 0 && binding !== interrupt && binding !== suspend) {
1274 this.#typeahead.push(event);
1275 return;
1276 }
1277 try {
1278 const late = this.editor.run(() => {
1279 if (typeof binding === "string") this.editor[binding]();
1280 else if (binding) binding(this.editor, this);
1281 else this.editor.type(event.text);
1282 });
1283 late?.then(() => this.#widget?.redraw());
1284 } catch (error) {
1285 this.#host.writeError(report(error) + "\n");
1286 }
1287 this.#widget?.redraw();
1288 }
1289
1290 /**
1291 * enter the line. it moves into the log above the prompt, and is run as a
1292 * command or evaluated. a line that is incomplete gets a new line instead,
1293 * and with a completion highlighted, that is taken first.
1294 */
1295 submit(): void {
1296 const { editor } = this;
1297 const { text, menu } = editor;
1298 if (menu && menu.options[menu.selected] !== text.slice(menu.from, editor.cursor)) {
1299 return editor.complete();
1300 }
1301 if (text.trim() === "") return;
1302 const state = editor.tokens.at(-1)?.state ?? this.#syntax.tokenizer.start;
1303 if (this.#syntax.incomplete?.(text, state)) return editor.newline();
1304
1305 const { prompt } = this.#look;
1306 const continued = margin(ansi.widthInTerminal(prompt), "…");
1307 const echo = paint(text, editor.tokens, this.#syntax.styles)
1308 .map((line, i) => (i === 0 ? prompt : continued) + line + "\n");
1309 this.#host.writeError(echo.join(""));
1310
1311 const remembered = editor.history.length;
1312 editor.accept();
1313 if (this.#historyFile && editor.history.length > remembered) {
1314 UNWRAP(node.builtin("fs"))
1315 .appendFileSync(this.#historyFile, JSON.stringify(text) + "\n", { mode: 0o600 });
1316 }
1317
1318 const [, name, args = ""] = commandLine.exec(text) ?? [];
1319 const entry = name !== undefined && Object.hasOwn(this.commands, name)
1320 ? this.commands[name]
1321 : undefined;
1322 const command = typeof entry === "function" ? { execute: entry } : entry;
1323 const language = this.#language;
1324 if (!command && !language) {
1325 this.#host.writeError(ansi.style(ansi.fgRed, "not a command") + "\n");
1326 return;
1327 }
1328
1329 const print = (value: unknown) => this.#host.writeError(inspect(value) + "\n");
1330 const run = async () => {
1331 if (!command) return UNWRAP(language).eval(text, print);
1332 using node = this.progress.start(text.trim());
1333 await command.execute.call(this, args.trim(), node);
1334 };
1335 // entered code starts off without the keyboard, so that a loop which
1336 // never yields is still ended by ctrl+c, as an interrupt from the terminal
1337 const lock = this.#host.getDrawLock("long");
1338 const work = run();
1339 lock.release("cursor-start-of-line");
1340
1341 const block = command ? command.block ?? false : true;
1342 if (block) this.#blocking += 1;
1343 work
1344 .catch((error) => this.#host.writeError(report(error) + "\n"))
1345 .finally(() => {
1346 if (block) this.#blocking -= 1;
1347 while (this.#blocking === 0 && this.#typeahead.length > 0) {
1348 this.input(UNWRAP(this.#typeahead.shift()));
1349 }
1350 this.#widget?.redraw();
1351 });
1352 }
1353
1354 /**
1355 * what ctrl+c means to a program that is not reading the keyboard: every
1356 * process attached to the terminal is sent an interrupt.
1357 */
1358 interrupt(): void {
1359 const process = node.process;
1360 if (!process) return;
1361 // the terminal is released first, as this might be the end
1362 const lock = this.#host.getDrawLock("long");
1363 process.kill(process.platform === "win32" ? process.pid : 0, "SIGINT");
1364 lock.release("cursor-start-of-line");
1365 }
1366
1367 /** what ctrl+z means: stop, handing the terminal to the shell until resumed */
1368 suspend(): void {
1369 const spawnSync = node.builtin("child_process")?.spawnSync;
1370 if (!spawnSync || node.process?.platform === "win32") return;
1371 const lock = this.#host.getDrawLock("long");
1372 // node.js assumes that a process signaling itself is about to exit, and
1373 // shuts down what `javascript` evaluates through. a shell signals
1374 // the process group instead, and returns once that is resumed.
1375 spawnSync("/bin/sh", ["-c", "kill -TSTP 0"], { stdio: "ignore" });
1376 lock.release("cursor-start-of-line");
1377 }
1378
1379 /** scroll the terminal clear, leaving the prompt at the top */
1380 clearScreen(): void {
1381 this.#host.writeError(ansi.cursorHome + ansi.clearToEndOfScreen);
1382 }
1383
1384 /** remove the prompt and stop reading the keyboard */
1385 close(): void {
1386 this.#widget?.stop();
1387 this.#widget = null;
1388 this.#typeahead = [];
1389 this.#detachStatus();
1390 }
1391
1392 [Symbol.dispose](): void {
1393 this.close();
1394 }
1395}
1396
1397type Syntax = Pick<Language, "tokenizer" | "styles" | "incomplete" | "complete">;
1398
1399const indent = " ";
1400const menuRows = 6;
1401const historyLimit = 1000;
1402const commandLine = /^\/([\w-]+)(?:\s+([\s\S]*))?$/;
1403const commandWord = /\/([\w-]+)(?!\S)/y;
1404/** the tokenizer state of a line that is a command */
1405const commandState = "\0/";
1406
1407let segmenter: Intl.Segmenter | null = null;
1408
1409/** a `language` where a line that names one of `commands` is not code */
1410function withCommands(
1411 language: Language | null,
1412 commands: Record<string, Command | CommandExecute>,
1413): Syntax {
1414 return {
1415 styles: { command: ansi.fgBlue, ...language?.styles },
1416 tokenizer: {
1417 start: language?.tokenizer.start ?? "",
1418 next(text, offset, state) {
1419 if (state === commandState) return { end: text.length, kind: "", state };
1420 if (offset === 0) {
1421 commandWord.lastIndex = 0;
1422 const name = commandWord.exec(text)?.[1];
1423 if (name !== undefined && Object.hasOwn(commands, name)) {
1424 return { end: name.length + 1, kind: "command", state: commandState };
1425 }
1426 }
1427 return language?.tokenizer.next(text, offset, state)
1428 ?? { end: text.length, kind: "", state };
1429 },
1430 },
1431 incomplete: (code, state) => state !== commandState && (language?.incomplete?.(code, state) ?? false),
1432 complete(code, cursor, asked, kind) {
1433 const typed = code.slice(0, cursor);
1434 const names = /^\/[\w-]*$/.test(typed)
1435 ? Object.keys(commands).filter((command) => /^[\w-]+$/.test(command)).sort()
1436 : [];
1437 if (names.length > 0) return { from: 1, options: names };
1438
1439 const [head, name = ""] = /^\/([\w-]+)\s+/.exec(typed) ?? [];
1440 if (head === undefined || !Object.hasOwn(commands, name)) {
1441 return language?.complete?.(code, cursor, asked, kind) ?? null;
1442 }
1443 const command = UNWRAP(commands[name]);
1444 const found = typeof command === "function" ? null : command.complete?.(typed.slice(head.length), asked);
1445 return found ? { from: head.length + found.from, options: found.options } : null;
1446 },
1447 };
1448}
1449
1450function readHistory(file: string): string[] {
1451 const fs = UNWRAP(node.builtin("fs"));
1452 const path = UNWRAP(node.builtin("path"));
1453 fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
1454 if (!fs.existsSync(file)) return [];
1455
1456 // a line of the file is a JSON string, since entries can span lines
1457 const entries = fs.readFileSync(file, "utf-8").split("\n").flatMap((line) => {
1458 try {
1459 const entry: unknown = JSON.parse(line);
1460 return typeof entry === "string" ? [entry] : [];
1461 } catch {
1462 return [];
1463 }
1464 });
1465 if (entries.length > historyLimit * 2) {
1466 entries.splice(0, entries.length - historyLimit);
1467 const lines = entries.map((entry) => JSON.stringify(entry) + "\n");
1468 fs.writeFileSync(file, lines.join(""), { mode: 0o600 });
1469 }
1470 return entries;
1471}
1472
1473function inspect(value: unknown): string {
1474 return node.builtin("util")?.inspect(value, { colors: true }) ?? String(value);
1475}
1476
1477/** what to print for something that entered code or a key binding threw */
1478function report(error: unknown): string {
1479 try {
1480 return error instanceof Error
1481 ? stack.format(error, true).trimEnd()
1482 : ansi.style(ansi.fgRed, "uncaught") + " " + inspect(error);
1483 } catch {
1484 // the error has something in it that cannot be formatted
1485 return exceptions.message(error);
1486 }
1487}
1488
1489function isWord(char: string) {
1490 // surrogates count so that a pair is never split
1491 return /[\p{L}\p{N}\p{M}\p{Cs}_$]/u.test(char);
1492}
1493
1494function lineStart(text: string, offset: number) {
1495 return offset === 0 ? 0 : text.lastIndexOf("\n", offset - 1) + 1;
1496}
1497
1498function lineEnd(text: string, offset: number) {
1499 const end = text.indexOf("\n", offset);
1500 return end === -1 ? text.length : end;
1501}
1502
1503/** `offset` within `text`, moved to an edge of the grapheme it is inside of */
1504function snap(text: string, offset: number, step: -1 | 1): number {
1505 const start = lineStart(text, Math.min(offset, text.length));
1506 const end = lineEnd(text, start);
1507 if (offset <= start || offset >= end) return Math.min(offset, end);
1508
1509 segmenter ??= new Intl.Segmenter();
1510 const line = text.slice(start, end);
1511 const { index, segment } = UNWRAP(segmenter.segment(line).containing(offset - start));
1512 if (start + index === offset) return offset;
1513 return start + index + (step < 0 ? 0 : segment.length);
1514}
1515
1516/** index of the first token that ends after `offset` */
1517function tokenIndex(tokens: Token[], offset: number) {
1518 let low = 0;
1519 let high = tokens.length;
1520 while (low < high) {
1521 const middle = (low + high) >> 1;
1522 if (UNWRAP(tokens[middle]).end > offset) high = middle;
1523 else low = middle + 1;
1524 }
1525 return low;
1526}
1527
1528interface Cell {
1529 offset: number;
1530 text: string;
1531 width: number;
1532}
1533
1534/**
1535 * how the graphemes of `text` between `from` and `to` are displayed, which
1536 * differs from the text itself for control characters. the range must be
1537 * within one line.
1538 */
1539function cells(text: string, from: number, to: number): Cell[] {
1540 const line = text.slice(from, to);
1541 if (printableAscii.test(line)) {
1542 return Array.from(line, (char, i) => ({ offset: from + i, text: char, width: 1 }));
1543 }
1544
1545 segmenter ??= new Intl.Segmenter();
1546 return Array.from(segmenter.segment(line), ({ segment, index }) => {
1547 const code = segment.charCodeAt(0);
1548 const offset = from + index;
1549 if (segment === "\t") return { offset, text: indent, width: indent.length };
1550 if (code < 0x20 || (code >= 0x7f && code < 0xa0)) {
1551 // control pictures stand in for what would otherwise move the cursor
1552 const picture = code < 0x20 ? 0x2400 + code : code === 0x7f ? 0x2421 : 0xfffd;
1553 return { offset, text: String.fromCharCode(picture), width: 1 };
1554 }
1555 return { offset, text: segment, width: ansi.widthInTerminal(segment) };
1556 });
1557}
1558const printableAscii = /^[\x20-\x7e]*$/;
1559
1560interface Row {
1561 from: number;
1562 to: number;
1563 /** whether this is the first row of its line */
1564 head: boolean;
1565}
1566
1567/** break `text` into rows at most `columns` cells wide */
1568function layout(text: string, columns: number): Row[] {
1569 const rows: Row[] = [];
1570 for (let start = 0; start <= text.length;) {
1571 const end = lineEnd(text, start);
1572 if (printableAscii.test(text.slice(start, end))) {
1573 for (let from = start; from === start || from < end; from += columns) {
1574 rows.push({ from, to: Math.min(end, from + columns), head: from === start });
1575 }
1576 } else {
1577 let from = start;
1578 let width = 0;
1579 for (const cell of cells(text, start, end)) {
1580 if (width + cell.width > columns && cell.offset > from) {
1581 rows.push({ from, to: cell.offset, head: from === start });
1582 from = cell.offset;
1583 width = 0;
1584 }
1585 width += cell.width;
1586 }
1587 rows.push({ from, to: end, head: from === start });
1588 }
1589 start = end + 1;
1590 }
1591 return rows;
1592}
1593
1594/** the styled lines of `text` */
1595function paint(text: string, tokens: Token[], styles: Record<string, string>): string[] {
1596 const lines: string[] = [];
1597 for (let start = 0; start <= text.length;) {
1598 const end = lineEnd(text, start);
1599 lines.push(paintLine(text, tokens, styles, start, end));
1600 start = end + 1;
1601 }
1602 return lines;
1603}
1604
1605/** `text` between `from` and `to`, which are within one line, as displayed */
1606function paintLine(
1607 text: string,
1608 tokens: Token[],
1609 styles: Record<string, string>,
1610 from: number,
1611 to: number,
1612): string {
1613 let out = "";
1614 let style = "";
1615 let index = tokenIndex(tokens, from);
1616 for (const cell of cells(text, from, to)) {
1617 while (index < tokens.length && UNWRAP(tokens[index]).end <= cell.offset) index += 1;
1618 const next = styles[tokens[index]?.kind ?? ""] ?? "";
1619 if (next !== style) out += (style && ansi.reset) + next;
1620 style = next;
1621 out += cell.text;
1622 }
1623 return out + (style && ansi.reset);
1624}
1625
1626/** a marker in the cells that the prompt occupies on the first row */
1627function margin(gutter: number, mark: string): string {
1628 if (gutter === 0) return "";
1629 return (mark && ansi.style(ansi.dim, mark)) + " ".repeat(gutter - mark.length);
1630}
1631
1632/**
1633 * where `option` matches `typed`, or null if it does not. a match is loose:
1634 * whatever the case, the typed characters may go on from one another, or
1635 * from the start of any later word (`fm` finds `formatMenu`, and `wu` finds
1636 * `wait-up`). failing that, they may be anywhere, if together.
1637 */
1638function fuzzy(option: string, typed: string): number[] | null {
1639 const lower = option.toLowerCase();
1640 const wanted = typed.toLowerCase();
1641 // the cases of a few characters differ in length, which would shift matches
1642 if (lower.length !== option.length) return option.startsWith(typed) ? [...typed].map((_, i) => i) : null;
1643
1644 const found: number[] = [];
1645 for (let i = 0, at = 0; i < wanted.length; i += 1, at += 1) {
1646 if (i === 0 || lower[at] !== wanted[i]) {
1647 while (at < lower.length && !(lower[at] === wanted[i] && isWordStart(option, at))) at += 1;
1648 }
1649 if (at >= lower.length) {
1650 const index = lower.indexOf(wanted);
1651 return index === -1 ? null : Array.from(wanted, (_, offset) => index + offset);
1652 }
1653 found.push(at);
1654 }
1655 return found;
1656}
1657
1658function isWordStart(text: string, index: number) {
1659 const before = text[index - 1];
1660 if (before === undefined || !/[\p{L}\p{N}]/u.test(before)) return true;
1661 return /\p{Lu}/u.test(UNWRAP(text[index])) && !/\p{Lu}/u.test(before);
1662}
1663
1664/**
1665 * the `options` that `typed` matches, best first: those it begins, then those
1666 * it is found in with the fewest jumps, the earliest.
1667 */
1668function narrow(options: string[], typed: string): string[] {
1669 const lower = typed.toLowerCase();
1670 const matches = options.flatMap((option) => {
1671 const found = fuzzy(option, typed);
1672 if (!found) return [];
1673 const jumps = found.filter((at, i) => i > 0 && at !== UNWRAP(found[i - 1]) + 1).length;
1674 const score = option.startsWith(typed)
1675 ? 0
1676 : option.toLowerCase().startsWith(lower)
1677 ? 1
1678 : 2 + jumps * option.length + (found[0] ?? 0);
1679 return [{ option, score }];
1680 });
1681 // the sort is stable, which leaves the order they were given in to decide
1682 return matches.sort((a, b) => a.score - b.score).map((match) => match.option);
1683}
1684
1685/**
1686 * up to `rows` of the menu's options as a box of one width, the page that
1687 * has the highlighted option, with what `typed` matched picked out.
1688 */
1689function formatMenu(menu: Menu, typed: string, rows: number): { lines: string[]; width: number } {
1690 const widest = menu.options
1691 .reduce((widest, option) => Math.max(widest, ansi.widthInTerminal(option)), 0);
1692 const first = menu.selected - menu.selected % rows;
1693 const lines = menu.options.slice(first, first + rows).map((option, i) => {
1694 let label = "";
1695 let from = 0;
1696 for (const at of fuzzy(option, typed) ?? []) {
1697 label += option.slice(from, at) + menuMatch[0] + option[at] + menuMatch[1];
1698 from = at + 1;
1699 }
1700 label += option.slice(from);
1701 const padding = " ".repeat(widest - ansi.widthInTerminal(option) + 1);
1702 return (first + i === menu.selected ? menuChosen : surface) + " " + label + padding + ansi.reset;
1703 });
1704 return { lines, width: widest + 2 };
1705}
1706
1707/** what a menu is drawn on */
1708const surface = ansi.bgBrightBlack + ansi.fgBrightWhite;
1709/** what a filled prompt is drawn on: a grey that is dark as a block, which bright black is as text */
1710const filled = ansi.bg256(237) + ansi.fgBrightWhite;
1711/** the highlighted option of a menu, and around a matched character */
1712const menuChosen = ansi.bgBlue + ansi.fgBrightWhite;
1713const menuMatch = [ansi.bold + ansi.fgBrightYellow, ansi.resetWeight + ansi.fgBrightWhite] as const;
1714
1715/** colors for the kinds of token that {@linkcode javascriptTokenizer} produces */
1716const javascriptStyles: Record<string, string> = {
1717 keyword: ansi.fgMagenta,
1718 literal: ansi.fgYellow,
1719 number: ansi.fgYellow,
1720 string: ansi.fgGreen,
1721 regexp: ansi.fgCyan,
1722 comment: ansi.fgBrightBlack,
1723 invalid: ansi.fgRed,
1724};
1725
1726/**
1727 * lexes javascript into the kinds `keyword`, `literal`, `number`, `string`,
1728 * `regexp`, `comment`, `punctuation`, `property`, `variable`, `invalid`, and
1729 * `""` for whitespace.
1730 *
1731 * the state is a stack of what is open, one character each: `(`, `[`, `{`, a
1732 * backtick for template text, `$` for a template substitution, and `*` for a
1733 * block comment. it is preceded by `!` once a bracket was mismatched, and
1734 * followed by what the next token follows: `r` if a slash would start a
1735 * regular expression, `d` if it would divide, `.` if a name would be a
1736 * property.
1737 */
1738const javascriptTokenizer: Tokenizer = {
1739 start: "r",
1740 next(text, offset, state) {
1741 const stack = state.slice(0, -1);
1742 const follows = state[state.length - 1]!;
1743 const top = stack[stack.length - 1];
1744 const token = (end: number, kind: string, next = state): Token => ({
1745 // an expression that matches nothing still has to make progress
1746 end: end > offset ? end : offset + (text.codePointAt(offset)! > 0xffff ? 2 : 1),
1747 kind,
1748 state: next,
1749 });
1750
1751 if (top === "`") {
1752 if (text.startsWith("${", offset)) return token(offset + 2, "punctuation", stack + "$r");
1753 if (text[offset] === "`") return token(offset + 1, "string", stack.slice(0, -1) + "d");
1754 return token(match(templateText, text, offset), "string");
1755 }
1756
1757 const char = text[offset]!;
1758 const space = match(whitespace, text, offset);
1759 if (space !== -1) return token(space, "");
1760
1761 if (char === "/") {
1762 const after = text[offset + 1];
1763 if (after === "/") return token(match(lineComment, text, offset), "comment");
1764 if (after === "*") {
1765 const close = text.indexOf("*/", offset + 2);
1766 return close === -1
1767 ? token(text.length, "comment", stack + "*" + follows)
1768 : token(close + 2, "comment");
1769 }
1770 // an unterminated one runs to the end of the line, like a string, so
1771 // that the token never depends on text beyond itself
1772 const end = follows === "r" ? match(regexp, text, offset) : -1;
1773 if (end !== -1) return token(end, "regexp", stack + "d");
1774 }
1775
1776 if (char === "'" || char === "\"") {
1777 return token(match(quoted, text, offset), "string", stack + "d");
1778 }
1779 if (char === "`") return token(offset + 1, "string", stack + "`" + follows);
1780
1781 const digits = match(numeral, text, offset);
1782 if (digits !== -1) return token(digits, "number", stack + "d");
1783
1784 const end = match(word, text, offset);
1785 if (end !== -1) {
1786 const name = text.slice(offset, end);
1787 if (follows === ".") return token(end, "property", stack + "d");
1788 if (literals.has(name)) return token(end, "literal", stack + "d");
1789 if (!keywords.has(name)) return token(end, "variable", stack + "d");
1790 return token(end, "keyword", stack + (name === "this" || name === "super" ? "d" : "r"));
1791 }
1792
1793 if ("([{".includes(char)) return token(offset + 1, "punctuation", stack + char + "r");
1794 const closes = closers[char];
1795 if (closes) {
1796 // after a block a slash starts a regular expression. after an object
1797 // literal it divides, which is rare enough to get wrong.
1798 const popped = stack.slice(0, -1);
1799 if (top === closes) return token(offset + 1, "punctuation", popped + (char === "}" ? "r" : "d"));
1800 if (top === "$" && char === "}") return token(offset + 1, "punctuation", popped + "d");
1801 return token(offset + 1, "invalid", (stack.startsWith("!") ? "" : "!") + stack + "d");
1802 }
1803
1804 const operator = match(punctuation, text, offset);
1805 if (operator !== -1) {
1806 const symbol = text.slice(offset, operator);
1807 const next = symbol === "." || symbol === "?."
1808 ? "."
1809 // these attach to an operand on either side, so they change nothing
1810 : symbol === "++" || symbol === "--"
1811 ? follows
1812 : "r";
1813 return token(operator, "punctuation", stack + next);
1814 }
1815 return token(offset, "invalid");
1816 },
1817};
1818
1819const keywords = new Set(
1820 "async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new of return static super switch this throw try typeof var void while with yield"
1821 .split(" "),
1822);
1823const literals = new Set(["true", "false", "null", "undefined", "NaN", "Infinity"]);
1824const closers: Partial<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
1825
1826const whitespace = /\s+/y;
1827const lineComment = /\/\/[^\n]*/y;
1828const word = /#?[\p{ID_Start}$_][\p{ID_Continue}$\u200c\u200d]*/uy;
1829// an exponent may be missing its digits, so that the token does not depend on
1830// what follows a sign that would otherwise be a token of its own
1831const numeral =
1832 /0[xX][\da-fA-F_]+n?|0[oO][0-7_]+n?|0[bB][01_]+n?|(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][+-]?[\d_]*)?n?/y;
1833const quoted = /'(?:[^'\\\n]|\\[\s\S])*'?|"(?:[^"\\\n]|\\[\s\S])*"?/y;
1834const templateText = /(?:[^`\\$]|\\[\s\S]|\$(?!\{))+/y;
1835const regexp = /\/(?:[^\/\\\[\n]|\\[^\n]|\[(?:[^\]\\\n]|\\[^\n])*\]?)+(?:\/[\p{ID_Continue}$]*)?/uy;
1836const punctuation =
1837 />>>=|\.\.\.|===|!==|\*\*=|<<=|>>=|>>>|&&=|\|\|=|\?\?=|=>|==|!=|<=|>=|&&|\|\||\?\?|\?\.(?!\d)|\+\+|--|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<|>>|\*\*|[-+*\/%&|^!~<>=?:;,.@]/y;
1838
1839const lineTerminator = /\r\n|[\n\r\u2028\u2029]/;
1840const identifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u;
1841/** a chain of property accesses, then the name being typed */
1842const completable = /((?:[\p{ID_Start}$_][\p{ID_Continue}$]*\s*\??\.\s*)*)([\p{ID_Start}$_][\p{ID_Continue}$]*)?$/u;
1843
1844/** where `expression` stops matching at `offset`, or -1 */
1845function match(expression: RegExp, text: string, offset: number): number {
1846 expression.lastIndex = offset;
1847 return expression.test(text) ? expression.lastIndex : -1;
1848}
1849
1850/**
1851 * the names of an object's properties, its own before those it inherits.
1852 * runs both here and, through its source text, inside inspector evaluations.
1853 * index keys are left out, as they are never valid after a dot.
1854 */
1855function propertyNames(value: unknown): string[] {
1856 const names = [];
1857 for (
1858 let object = value == null ? null : Object(value);
1859 object;
1860 object = Object.getPrototypeOf(object)
1861 ) {
1862 for (const name of Object.getOwnPropertyNames(object).sort()) {
1863 const code = name.charCodeAt(0);
1864 if (code < 48 || code > 57) names.push(name);
1865 }
1866 }
1867 return names;
1868}
1869
1870/**
1871 * code evaluated through the inspector reaches this module by a global.
1872 * the key is unique to this copy of the library.
1873 */
1874const bridge = {
1875 take(value: unknown): void {
1876 taken = value;
1877 },
1878 /**
1879 * scripts compiled by the inspector cannot `import()`, so the keyword is
1880 * replaced by a call to this, which resolves relative paths from the
1881 * working directory, as there is no importing file.
1882 */
1883 import(specifier: string, options?: { with?: Record<string, string> }): Promise<unknown> {
1884 if (/^\.\.?\//.test(specifier)) {
1885 const file = UNWRAP(node.builtin("path")).resolve(specifier);
1886 specifier = UNWRAP(node.builtin("url")).pathToFileURL(file).href;
1887 }
1888 return import(specifier, options);
1889 },
1890};
1891const bridgeSource = `globalThis[Symbol.for(${JSON.stringify(import.meta.url)})]`;
1892let taken: unknown;
1893
1894function bridgeImports(code: string): string {
1895 const tokens: (Token & { start: number })[] = [];
1896 let state = javascriptTokenizer.start;
1897 for (let offset = 0; offset < code.length;) {
1898 const token = javascriptTokenizer.next(code, offset, state);
1899 if (token.kind !== "" && token.kind !== "comment") tokens.push({ ...token, start: offset });
1900 offset = token.end;
1901 state = token.state;
1902 }
1903 const text = (token = { start: 0, end: 0 }) => code.slice(token.start, token.end);
1904
1905 let out = "";
1906 let copied = 0;
1907 for (const [i, token] of tokens.entries()) {
1908 if (token.kind !== "keyword" || text(token) !== "import" || text(tokens[i + 1]) !== "(") {
1909 continue;
1910 }
1911 // a method that is named import has a body after its parameters
1912 const close = tokens.findIndex((other, at) => at > i + 1 && other.state.length <= token.state.length);
1913 if (close !== -1 && text(tokens[close + 1]) === "{") continue;
1914
1915 out += code.slice(copied, token.start) + bridgeSource + ".import";
1916 copied = token.end;
1917 }
1918 return out + code.slice(copied);
1919}
1920
1921/** a RemoteObject of the Chrome DevTools Protocol */
1922interface Remote {
1923 objectId?: string;
1924 unserializableValue?: string;
1925 value?: unknown;
1926}
1927
1928/** replies are typed `any`, as their shape depends on the method */
1929interface Inspector {
1930 /** the reply to a method that answers before returning, as most do */
1931 ask(method: string, params: object): any;
1932 evaluate(params: object): Promise<any>;
1933 /** the value that a RemoteObject stands for */
1934 take(remote: Remote): unknown;
1935}
1936
1937/** an inspector session into this same thread, if the runtime has a usable one */
1938function connect(): Inspector | null {
1939 try {
1940 const session = new (UNWRAP(node.builtin("inspector")).Session)();
1941 session.connect();
1942
1943 const ask: Inspector["ask"] = (method, params) => {
1944 let reply;
1945 session.post(method, params, (_, result) => {
1946 reply = result;
1947 });
1948 return reply;
1949 };
1950 // deno has the module, but answers later
1951 if (ask("Runtime.evaluate", { expression: "0" })?.result?.value !== 0) return null;
1952
1953 Object.defineProperty(globalThis, Symbol.for(import.meta.url), {
1954 value: bridge,
1955 configurable: true,
1956 });
1957 return {
1958 ask,
1959 evaluate: (params) =>
1960 new Promise((resolve, reject) => {
1961 session.post("Runtime.evaluate", params, (error, result) => {
1962 if (error) reject(error);
1963 else resolve(result);
1964 });
1965 }),
1966 take(remote) {
1967 if (remote.objectId) {
1968 ask("Runtime.callFunctionOn", {
1969 objectId: remote.objectId,
1970 functionDeclaration: `function(){"use strict";${bridgeSource}.take(this)}`,
1971 });
1972 const value = taken;
1973 taken = undefined;
1974 return value;
1975 }
1976 const literal = remote.unserializableValue;
1977 if (literal === undefined) return remote.value;
1978 return literal.endsWith("n") ? BigInt(literal.slice(0, -1)) : Number(literal);
1979 },
1980 };
1981 } catch {
1982 return null;
1983 }
1984}
1985
1986import { ASSERT, UNWRAP } from "./assert.ts";
1987import * as exceptions from "./exception.ts";
1988import * as log from "./log.ts";
1989import * as stack from "./log/stack.ts";
1990import * as node from "./node.ts";
1991import * as progress from "./progress.ts";
1992import * as string from "./string.ts";
1993import * as ansi from "./string/ansi.ts";
1994import * as ts from "./ts.ts";
lib/string/ansi.ts+10
...@@ -26,6 +26,14 @@ export const dim: string = `\x1b[2m`;...@@ -26,6 +26,14 @@ export const dim: string = `\x1b[2m`;
26/** resets `bold` and `dim` */26/** resets `bold` and `dim` */
27export const resetWeight: string = `\x1b[22m`;27export const resetWeight: string = `\x1b[22m`;
2828
29/**
30 * swaps the foreground and background colors, which reads as a highlight in
31 * any color scheme. reset with `resetInverse`.
32 */
33export const inverse: string = `\x1b[7m`;
34/** resets `inverse` */
35export const resetInverse: string = `\x1b[27m`;
36
29/** resets foreground color */37/** resets foreground color */
30export const fgReset: string = `\x1b[39m`;38export const fgReset: string = `\x1b[39m`;
31/** sets the foreground color to black. on light mode terminals, this color may39/** sets the foreground color to black. on light mode terminals, this color may
...@@ -152,6 +160,8 @@ export function cursorRight(n: number): string {...@@ -152,6 +160,8 @@ export function cursorRight(n: number): string {
152export function cursorLeft(n: number): string {160export function cursorLeft(n: number): string {
153 return n ? `\x1b[${n}D` : "";161 return n ? `\x1b[${n}D` : "";
154}162}
163/** move the cursor to the top left of the screen. */
164export const cursorHome: string = "\x1b[H";
155/** move the cursor to the start of it's current line. */165/** move the cursor to the start of it's current line. */
156export const startOfLine: string = "\r";166export const startOfLine: string = "\r";
157/** move the cursor to the start of the next line. */167/** move the cursor to the start of the next line. */
lib/testing.ts+260
...@@ -143,6 +143,231 @@ export class FakeTimers {...@@ -143,6 +143,231 @@ export class FakeTimers {
143 next(): (typeof this.entries)[number] | undefined {143 next(): (typeof this.entries)[number] | undefined {
144 return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0];144 return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0];
145 }145 }
146
147 /** let `ms` pass, firing the timers that come due in order */
148 advance(ms: number): void {
149 const end = this.time + ms;
150 let entry;
151 while ((entry = this.next()) && entry.deadline <= end) {
152 this.entries.splice(this.entries.indexOf(entry), 1);
153 this.time = entry.deadline;
154 entry.resolve();
155 }
156 this.time = end;
157 }
158}
159
160/**
161 * a terminal emulator covering the escape sequences `log.ts` writes, to
162 * assert on what ends up on screen instead of on how it was drawn.
163 */
164export class VirtualTerminal {
165 columns: number;
166 rows: number;
167 /** cells of every line, including those scrolled off the top */
168 #lines: string[][] = [[]];
169 /** the lines that only began because the line before them was full */
170 #wrapped = new Set<string[]>();
171 /** index in `#lines` of the first row on screen */
172 #top = 0;
173 #row = 0;
174 /** equal to `columns` when the cursor is waiting to wrap */
175 #column = 0;
176 /** whether text that reaches the last column continues on the next row */
177 #wraps = true;
178 /**
179 * resize the way xterm.js does, which most terminals inside of editors
180 * are: the cursor's line is cut off instead of wrapped, and the cursor
181 * moves down for every row that is added, even under it.
182 */
183 xtermjs = false;
184 #saved = { row: 0, column: 0 };
185
186 constructor(columns: number, rows: number) {
187 this.columns = columns;
188 this.rows = rows;
189 }
190
191 /** everything written, scrollback included, without trailing blanks */
192 get text(): string {
193 return this.#lines
194 .map((cells) => Array.from(cells, (cell) => cell ?? " ").join("").trimEnd())
195 .join("\n")
196 .trimEnd();
197 }
198
199 /** position in terms of `text`: `line` is a line index, `column` is in cells */
200 get cursor(): { line: number; column: number } {
201 return {
202 line: this.#top + this.#row,
203 column: Math.min(this.#column, this.columns - 1),
204 };
205 }
206
207 write(data: string): void {
208 const sequence = /\x1b(?:\[([0-?]*)[ -\/]*([@-~])|[78])/y;
209 const segmenter = new Intl.Segmenter();
210 for (let i = 0; i < data.length;) {
211 const char = data[i]!;
212 if (char === "\x1b") {
213 sequence.lastIndex = i;
214 const match = UNWRAP(sequence.exec(data), "unknown escape sequence");
215 this.#control(match[0], match[1] ?? "", match[2]);
216 i += match[0].length;
217 } else if (char === "\n") {
218 this.#newline();
219 i += 1;
220 } else if (char === "\r") {
221 this.#column = 0;
222 i += 1;
223 } else {
224 const end = data.slice(i).search(/[\x1b\n\r]/);
225 const text = end === -1 ? data.slice(i) : data.slice(i, i + end);
226 for (const { segment } of segmenter.segment(text)) this.#print(segment);
227 i += text.length;
228 }
229 }
230 }
231
232 #line(row: number): string[] {
233 return this.#lines[this.#top + row] ??= [];
234 }
235
236 /** empty a line, which also ends its being part of the line before it */
237 #clear(row: number) {
238 const line = this.#line(row);
239 line.length = 0;
240 this.#wrapped.delete(line);
241 }
242
243 /**
244 * blank the cursor's line from `column` on. as in tmux, a line that keeps
245 * its start stays as long as it was, which is how long it is when wrapped.
246 */
247 #clearFrom(column: number) {
248 if (column === 0) this.#clear(this.#row);
249 else this.#line(this.#row).fill(" ", column);
250 }
251
252 /**
253 * change the size the way terminals do: lines that were wrapped are
254 * wrapped again to the new width, and the cursor stays with its text.
255 */
256 resize(columns: number, rows: number): void {
257 const cursorLine = this.#line(this.#row);
258 const topLine = this.#line(0);
259 const lines: string[][] = [];
260 let cursor = { line: 0, column: 0 };
261 let top = 0;
262 let passedCursor = false;
263
264 // put together the text of each run of wrapped lines, then wrap it anew
265 for (let i = 0; i < this.#lines.length;) {
266 const run: string[][] = [];
267 const text: string[] = [];
268 let offset = -1;
269 const first = lines.length;
270 do {
271 const line = UNWRAP(this.#lines[i]);
272 if (line === cursorLine) offset = text.length + this.#column;
273 if (line === topLine) top = first;
274 run.push(line);
275 text.push(...Array.from(line, (cell) => cell ?? " "));
276 i += 1;
277 } while (i < this.#lines.length && this.#wrapped.has(UNWRAP(this.#lines[i])));
278
279 if (this.xtermjs && offset !== -1) {
280 for (const line of run) line.length = Math.min(line.length, columns);
281 cursor = { line: first + run.indexOf(cursorLine), column: Math.min(this.#column, columns - 1) };
282 lines.push(...run);
283 passedCursor = true;
284 continue;
285 }
286
287 for (let start = 0; start === 0 || start < text.length;) {
288 // a wide character is not split from the cell that it continues into
289 let end = Math.min(text.length, start + columns);
290 if (text[end] === "" && end > start + 1) end -= 1;
291 if (offset >= start && (offset < end || end === text.length)) {
292 cursor = { line: lines.length, column: Math.min(offset - start, columns) };
293 }
294 const cells = text.slice(start, end);
295 if (start > 0) this.#wrapped.add(cells);
296 lines.push(cells);
297 start = Math.max(end, start + 1);
298 }
299 if (this.xtermjs && passedCursor) cursor.line += lines.length - first - run.length;
300 }
301
302 this.columns = columns;
303 this.rows = rows;
304 this.#lines = lines;
305 // what was on screen stays there, unless the cursor would leave it
306 this.#top = Math.max(top, cursor.line - rows + 1);
307 this.#row = cursor.line - this.#top;
308 this.#column = cursor.column;
309 }
310
311 #newline(wrapped = false) {
312 this.#column = 0;
313 if (this.#row === this.rows - 1) this.#top += 1;
314 else this.#row += 1;
315 const line = this.#line(this.#row);
316 if (wrapped) this.#wrapped.add(line);
317 }
318
319 #print(grapheme: string) {
320 const width = ansi.widthInTerminal(grapheme);
321 if (width === 0) return;
322 if (this.#column + width > this.columns) {
323 if (this.#wraps) this.#newline(true);
324 else this.#column = Math.max(0, this.columns - width);
325 }
326 const line = this.#line(this.#row);
327 line[this.#column] = grapheme;
328 for (let i = 1; i < width; i += 1) line[this.#column + i] = "";
329 this.#column += width;
330 }
331
332 #control(sequence: string, params: string, final: string | undefined) {
333 if (sequence === "\x1b7") {
334 this.#saved = { row: this.#row, column: this.#column };
335 return;
336 }
337 if (sequence === "\x1b8") {
338 this.#row = this.#saved.row;
339 this.#column = this.#saved.column;
340 return;
341 }
342 if (params === "?7") this.#wraps = final === "h";
343 // other private sequences set modes that have no effect on the grid
344 if (/^[<=>?]/.test(params)) return;
345
346 const [first = 0, second = 0] = params.split(";").map(Number);
347 const count = first || 1;
348 const column = Math.min(this.#column, this.columns - 1);
349 const line = this.#line(this.#row);
350 if (final === "A") this.#row = Math.max(0, this.#row - count);
351 else if (final === "B") this.#row = Math.min(this.rows - 1, this.#row + count);
352 else if (final === "C") this.#column = Math.min(this.columns - 1, column + count);
353 else if (final === "D") this.#column = Math.max(0, column - count);
354 else if (final === "H") {
355 this.#row = Math.min(this.rows, first || 1) - 1;
356 this.#column = Math.min(this.columns, second || 1) - 1;
357 } else if (final === "K") {
358 if (first === 0) this.#clearFrom(column);
359 else if (first === 1) line.fill(" ", 0, column + 1);
360 else this.#clear(this.#row);
361 } else if (final === "J") {
362 const from = first === 0 ? this.#row + 1 : 0;
363 if (first === 0) this.#clearFrom(column);
364 for (let row = from; row < this.rows; row += 1) {
365 if (this.#top + row < this.#lines.length) this.#clear(row);
366 }
367 } else if (final !== "m") {
368 throw new Error(`unknown escape sequence: ${JSON.stringify(sequence)}`);
369 }
370 }
146}371}
147372
148export function removeUndefinedKeys(obj: unknown): unknown {373export function removeUndefinedKeys(obj: unknown): unknown {
...@@ -171,6 +396,8 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -171,6 +396,8 @@ export class MockScreen implements Disposable, log.WidgetHost {
171 writeCalls: WriteCall[] = [];396 writeCalls: WriteCall[] = [];
172397
173 timers: FakeTimers = new FakeTimers();398 timers: FakeTimers = new FakeTimers();
399 /** receives everything that lands on screen, across frames */
400 terminal: VirtualTerminal = new VirtualTerminal(this.columns, this.rows);
174401
175 writeOutput: log.WidgetHost["writeOutput"];402 writeOutput: log.WidgetHost["writeOutput"];
176 writeError: log.WidgetHost["writeError"];403 writeError: log.WidgetHost["writeError"];
...@@ -181,6 +408,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -181,6 +408,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
181408
182 hasTerminalLock: null | "locked" | "temporary-unlock" = null;409 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
183 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();410 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();
411 #input: ((data: string) => void) | null = null;
184412
185 static sync(text: string[]): string {413 static sync(text: string[]): string {
186 return ansi.syncStart + text.join("") + ansi.syncEnd;414 return ansi.syncStart + text.join("") + ansi.syncEnd;
...@@ -199,6 +427,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -199,6 +427,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
199 writeInteractive: (content) => {427 writeInteractive: (content) => {
200 this.stderr += content;428 this.stderr += content;
201 this.out += content;429 this.out += content;
430 this.terminal.write(content);
202 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);431 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
203 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);432 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
204 this.writeCalls.push({433 this.writeCalls.push({
...@@ -210,6 +439,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -210,6 +439,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
210 writeOutput: (content) => {439 writeOutput: (content) => {
211 this.stdout += content;440 this.stdout += content;
212 this.out += content;441 this.out += content;
442 if (outputSharesScreen ?? true) this.terminal.write(content);
213 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);443 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
214 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);444 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
215 this.writeCalls.push({445 this.writeCalls.push({
...@@ -242,6 +472,13 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -242,6 +472,13 @@ export class MockScreen implements Disposable, log.WidgetHost {
242 delay: this.timers.delay,472 delay: this.timers.delay,
243 color: true,473 color: true,
244 outputSharesScreen,474 outputSharesScreen,
475 observeInput: (callback) => {
476 ASSERT(this.hasTerminalLock && !this.#input);
477 this.#input = callback;
478 return () => {
479 this.#input = null;
480 };
481 },
245 });482 });
246 this.writeOutput = host.writeOutput;483 this.writeOutput = host.writeOutput;
247 this.writeError = host.writeError;484 this.writeError = host.writeError;
...@@ -255,9 +492,32 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -255,9 +492,32 @@ export class MockScreen implements Disposable, log.WidgetHost {
255 resize(columns: number, rows: number = this.rows) {492 resize(columns: number, rows: number = this.rows) {
256 this.columns = columns;493 this.columns = columns;
257 this.rows = rows;494 this.rows = rows;
495 this.terminal.resize(columns, rows);
258 this.#sizeObservers.forEach((cb) => cb({ columns, rows }));496 this.#sizeObservers.forEach((cb) => cb({ columns, rows }));
259 }497 }
260498
499 /** whether the keyboard is being read (the terminal is in raw mode) */
500 get reading(): boolean {
501 return this.#input !== null;
502 }
503
504 /** simulate the terminal sending keyboard input */
505 type(data: string) {
506 UNWRAP(this.#input, "the keyboard is not being read")(data);
507 }
508
509 /**
510 * let `ms` pass, drawing whatever comes due. the writes are dropped
511 * instead of being held for `expectFrame`; observe `terminal` instead.
512 */
513 advance(ms = 0) {
514 this.timers.advance(ms);
515 this.stdout = "";
516 this.stderr = "";
517 this.out = "";
518 this.writeCalls = [];
519 }
520
261 expectNone() {521 expectNone() {
262 ASSERT(this.timers.entries.length === 0);522 ASSERT(this.timers.entries.length === 0);
263 }523 }