| author | |
| committer | |
| log | 3c3cdcccc9b633ab2ea0e14158e780bab6bbed15 |
| tree | c6265b919f77cf39d42dc026ef828e28f2e78597 |
| parent | 27372f562b13b96c1bf165339923d5f402753428 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
closes #69
Assisted-by: claude-opus-5.513 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`. | ||
| 4 | const console = log.scoped("http"); | ||
| 5 | const server = { | ||
| 6 | requests: 0, | ||
| 7 | bytesSent: 0, | ||
| 8 | visitors: new Map<string, number>(), | ||
| 9 | startedAt: new Date(), | ||
| 10 | }; | ||
| 11 | |||
| 12 | new 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 | |||
| 44 | async 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 | |||
| 76 | function* cycle<T>(items: T[]) { | ||
| 77 | while (true) yield* items; | ||
| 78 | } | ||
| 79 | |||
| 80 | import * as async from "../async.ts"; | ||
| 81 | import * as log from "../log.ts"; | ||
| 82 | import * as progress from "../progress.ts"; | ||
| 83 | import * 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 | }); |
| 32 | 32 | ||
| 33 | /** what clears a widget block from its first row, after a resize */ | ||
| 34 | const erase = "\r" + ansi.clearFullLine + ansi.cursorRight(1) + ansi.clearToEndOfScreen + "\r"; | ||
| 35 | |||
| 33 | // these tests for `startWidget` are built on a custom widget host | 36 | // these tests for `startWidget` are built on a custom widget host |
| 34 | // implementation that buffers all bytes in memory. it is a great | 37 | // 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.clearToEndOfScreen | 900 | 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 | }); |
| 904 | 904 | ||
| 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 | }); |
| 1095 | 1176 | ||
| 1177 | describe("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 | |||
| 1197 | describe("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 | |||
| 1619 | import assert from "node:assert/strict"; | ||
| 1096 | import { describe, test } from "vitest"; | 1620 | import { describe, test } from "vitest"; |
| 1097 | import { ASSERT, UNWRAP } from "./assert.ts"; | 1621 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1098 | import * as log from "./log.ts"; | 1622 | import * 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 is | 32 | * 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 that | 34 | * 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 show | 40 | * - 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 | */ | ||
| 334 | export 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 | */ | ||
| 354 | export 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 | } |
| 321 | 362 | ||
| 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 | } |
| 351 | 394 | ||
| 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 true | 426 | * @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 | } |
| 387 | 437 | ||
| 388 | /** | 438 | /** |
| ... | @@ -455,6 +505,9 @@ interface WidgetState { | ... | @@ -455,6 +505,9 @@ interface WidgetState { |
| 455 | const flushSyncBytes = 65536; | 505 | const flushSyncBytes = 65536; |
| 456 | const flushSyncMs = 50; | 506 | const flushSyncMs = 50; |
| 457 | 507 | ||
| 508 | /** how long a lone escape byte may wait to become the start of a sequence */ | ||
| 509 | const escapeMs = 50; | ||
| 510 | |||
| 458 | /** | 511 | /** |
| 459 | * terminal widget rendering is done by specifying all system APIs up front in | 512 | * 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; |
| 462 | export function createTerminalWidgetHost( | 515 | export 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; |
| 467 | 520 | ||
| 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; | ||
| 495 | 560 | ||
| 496 | // the size subscription lives exactly as long as the terminal lock, so | 561 | // 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 | } |
| 521 | 587 | ||
| 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 would | 668 | // 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( |
| 565 | 700 | ||
| 566 | // trivial path when not using widgets | 701 | // 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 torn | 703 | if (buffer.length) { |
| 569 | // down before the timer fired); it is a no-op, not an error | 704 | 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 widget | 731 | 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); |
| 628 | 776 | ||
| 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; |
| 630 | 792 | ||
| 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 space | 798 | // 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 buffer | 864 | // clear the lines for buffer |
| 701 | ? (clearLinesTop > 0 | 865 | ? (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 next | 885 | // 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 text | 888 | + 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.reset | 891 | + 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; widgets | 904 | // the first draw can land just after a partial log line; widgets |
| 748 | // must move below it. (`partialWidth` is zero whenever | 905 | // must move below it. (`partialWidth` is zero whenever |
| 749 | // `needsToSaveCursor` is set, since releasing a draw lock resets | 906 | // `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 space | 914 | // skip up the widget space |
| 758 | + ansi.cursorUp(lines.length - clearLinesBottom) | 915 | + ansi.cursorUp(lines.length - clearLinesBottom) |
| 759 | // the widget text | 916 | + 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 | } |
| 780 | 944 | ||
| 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.syncStart | 1002 | ansi.syncStart |
| 1003 | + unpark() | ||
| 839 | // clear the widget space | 1004 | // 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 | } |
| 1033 | 1204 | ||
| 1205 | /** starts and stops bracketed paste, around reading the keyboard */ | ||
| 1206 | const pasteEnable = "\x1b[?2004h"; | ||
| 1207 | const 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 | */ | ||
| 1212 | const 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 | */ | ||
| 1217 | const keyboardEnhance = "\x1b[>1u"; | ||
| 1218 | const 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 | */ | ||
| 1223 | const versionQuery = "\x1b[>c"; | ||
| 1224 | const xtermjsVersion = ">0;276;0"; | ||
| 1225 | /** stops and starts the terminal continuing on the next row at its last column */ | ||
| 1226 | const wrapOff = "\x1b[?7l"; | ||
| 1227 | const wrapOn = "\x1b[?7h"; | ||
| 1228 | const 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 | */ | ||
| 1237 | class 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 | ||
| 1397 | const modShift = 1; | ||
| 1398 | const modAlt = 2; | ||
| 1399 | const modCtrl = 4; | ||
| 1400 | /** the lock keys are reported in higher bits, and are not keyModifiers */ | ||
| 1401 | const keyModifierMask = 0b111111; | ||
| 1402 | const 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 | |||
| 1411 | const keyText: Partial<Record<string, string>> = { | ||
| 1412 | enter: "\n", | ||
| 1413 | tab: "\t", | ||
| 1414 | space: " ", | ||
| 1415 | }; | ||
| 1416 | |||
| 1417 | const 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 | |||
| 1429 | const ss3Keys: Partial<Record<string, string>> = { ...csiKeys, R: "f3" }; | ||
| 1430 | |||
| 1431 | const linuxKeys: Partial<Record<string, string>> = { | ||
| 1432 | A: "f1", | ||
| 1433 | B: "f2", | ||
| 1434 | C: "f3", | ||
| 1435 | D: "f4", | ||
| 1436 | E: "f5", | ||
| 1437 | }; | ||
| 1438 | |||
| 1439 | const 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 */ | ||
| 1463 | const 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 | |||
| 1482 | let graphemes: Intl.Segmenter | null = null; | ||
| 1483 | |||
| 1484 | function inRange(code: number, min: number, max: number) { | ||
| 1485 | return code >= min && code <= max; | ||
| 1486 | } | ||
| 1487 | |||
| 1488 | function isControlCode(code: number) { | ||
| 1489 | return code < 0x20 || inRange(code, 0x7f, 0x9f); | ||
| 1490 | } | ||
| 1491 | |||
| 1492 | function 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 | |||
| 1497 | function 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 | |||
| 1502 | function 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 */ | ||
| 1517 | function 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 | |||
| 1528 | function 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 */ | ||
| 1562 | function isCodePoint(code: number) { | ||
| 1563 | return Number.isInteger(code) && code <= 0x10ffff; | ||
| 1564 | } | ||
| 1565 | |||
| 1034 | const logColors = node.process?.stdout.isTTY ?? false; | 1566 | const logColors = node.process?.stdout.isTTY ?? false; |
| 1035 | 1567 | ||
| 1036 | let formatLine = /* @__PURE__ */ (() => { | 1568 | let formatLine = /* @__PURE__ */ (() => { |
| ... | @@ -1182,7 +1714,7 @@ interface GlobalCommunication { | ... | @@ -1182,7 +1714,7 @@ interface GlobalCommunication { |
| 1182 | } | 1714 | } |
| 1183 | 1715 | ||
| 1184 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); | 1716 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); |
| 1185 | const version = 5; | 1717 | const version = 6; |
| 1186 | let global: GlobalCommunication = /* @__PURE__ */ (() => { | 1718 | let 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 | } |
| 1383 | 1916 | ||
| 1917 | /** reads `process.stdin` in raw mode, if it is a terminal */ | ||
| 1918 | function 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 | |||
| 1384 | function defaultFallbackWidgetHost(): WidgetHost { | 1964 | function 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 | } |
| 1411 | 1991 | ||
| 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 | */ | ||
| 1413 | export function simpleNodeProcessWidgetHost( | 1996 | export 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 @@ | ||
| 1 | test("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 | |||
| 8 | import assert from "node:assert/strict"; | ||
| 9 | import { test } from "vitest"; | ||
| 10 | import * 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 { |
| 54 | export function parse(error: Error | string): Frame[] | null { | 54 | export 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 | } |
| 122 | 124 | ||
| 123 | return out + "\n"; | 125 | return out; |
| 124 | } | 126 | } |
| 125 | 127 | ||
| 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 | } |
| 30 | 37 | ||
| 38 | /** the raw mode members are only present when the stream is a tty */ | ||
| 39 | interface 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 | |||
| 31 | interface Tty { | 49 | interface 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 | */ |
| 47 | interface Builtins { | 65 | interface 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 library | 1 | # notable changes in clover's typescript library |
| 2 | 2 | ||
| 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 | ## v7 | 13 | ## v7 |
| 4 | 14 | ||
| 5 | ### features | 15 | ### 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 runtime | 27 | - `node` - the Node.js javascript runtime |
| 28 | - `progress` - cli progress bars, nesting status indication | 28 | - `progress` - cli progress bars, nesting status indication |
| 29 | - `queue` - priority queue with rescheduling | 29 | - `queue` - priority queue with rescheduling |
| 30 | - `repl` - interactive prompt for long running programs | ||
| 30 | - `stream` - ReadableStream helpers | 31 | - `stream` - ReadableStream helpers |
| 31 | - `string` - trival helpers | 32 | - `string` - trival helpers |
| 32 | - `string/ansi` - cursor and color constants | 33 | - `string/ansi` - cursor and color constants |
lib/repl.test.ts created+1581| ... | @@ -0,0 +1,1581 @@ | ||
| 1 | const javascript = repl.javascript(); | ||
| 2 | /** for an editor that only lexes */ | ||
| 3 | const syntax = { tokenizer: javascript.tokenizer }; | ||
| 4 | |||
| 5 | /** kinds and text of the tokens that are not whitespace */ | ||
| 6 | function 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 | |||
| 15 | function stateAfter(code: string): string { | ||
| 16 | return repl.tokenize(javascript.tokenizer, code).at(-1)?.state ?? javascript.tokenizer.start; | ||
| 17 | } | ||
| 18 | |||
| 19 | describe("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 */ | ||
| 162 | function 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 | |||
| 173 | describe("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 | |||
| 275 | test("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 */ | ||
| 284 | function 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 */ | ||
| 293 | function show(editor: repl.Editor): string { | ||
| 294 | return editor.text.slice(0, editor.cursor) + "|" + editor.text.slice(editor.cursor); | ||
| 295 | } | ||
| 296 | |||
| 297 | describe("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 | |||
| 575 | describe("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 | |||
| 652 | describe("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 */ | ||
| 855 | function 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 */ | ||
| 878 | function linesOf(screen: testing.MockScreen): string[] { | ||
| 879 | return screen.terminal.text.split("\n"); | ||
| 880 | } | ||
| 881 | |||
| 882 | /** let promise reactions run, then draw */ | ||
| 883 | async function settle(screen: testing.MockScreen, ms = 0) { | ||
| 884 | await new Promise((resolve) => setImmediate(resolve)); | ||
| 885 | screen.advance(ms); | ||
| 886 | } | ||
| 887 | |||
| 888 | describe("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 | */ | ||
| 1317 | function 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 | |||
| 1343 | describe("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 | |||
| 1400 | describe("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 | |||
| 1572 | import assert from "node:assert/strict"; | ||
| 1573 | import * as fs from "node:fs"; | ||
| 1574 | import * as os from "node:os"; | ||
| 1575 | import * as path from "node:path"; | ||
| 1576 | import * as util from "node:util"; | ||
| 1577 | import { describe, test } from "vitest"; | ||
| 1578 | import { ASSERT, UNWRAP } from "./assert.ts"; | ||
| 1579 | import * as repl from "./repl.ts"; | ||
| 1580 | import * as ansi from "./string/ansi.ts"; | ||
| 1581 | import * 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} */ | ||
| 32 | export 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 | */ | ||
| 70 | export type Style = "plain" | "lines" | "filled"; | ||
| 71 | |||
| 72 | export type CommandExecute = (this: Repl, args: string, node: progress.Node) => Promise<void> | void; | ||
| 73 | export 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 */ | ||
| 93 | export 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 | */ | ||
| 104 | export 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 | |||
| 137 | export 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 | */ | ||
| 146 | export 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 | |||
| 158 | export 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} */ | ||
| 168 | export 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 | */ | ||
| 189 | export 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 | */ | ||
| 317 | export 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 | */ | ||
| 335 | export 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 | */ | ||
| 369 | export 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 | |||
| 413 | type Request = | ||
| 414 | | { id: number; eval: string } | ||
| 415 | | { id: number; complete: Parameters<NonNullable<Language["complete"]>> }; | ||
| 416 | type 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 */ | ||
| 423 | const wire = "@clo/lib/repl"; | ||
| 424 | |||
| 425 | /** send a message, to hear through `sent` whether it could be. node.js would otherwise throw from nowhere. */ | ||
| 426 | function 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 | |||
| 431 | function 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 */ | ||
| 438 | function formatted(text: string): unknown { | ||
| 439 | return { [Symbol.for("nodejs.util.inspect.custom")]: () => text, toString: () => text }; | ||
| 440 | } | ||
| 441 | |||
| 442 | /** lex all of `text` */ | ||
| 443 | export 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 | */ | ||
| 456 | export 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 */ | ||
| 499 | export 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} */ | ||
| 507 | export 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 | */ | ||
| 525 | export 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 | |||
| 955 | interface 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 | */ | ||
| 968 | export type Binding = EditorAction | ((editor: Editor, repl: Repl) => void); | ||
| 969 | type 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 | */ | ||
| 981 | export 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 | |||
| 1039 | function interrupt(editor: Editor, repl: Repl) { | ||
| 1040 | if (editor.text || editor.search) editor.clear(); | ||
| 1041 | else repl.interrupt(); | ||
| 1042 | } | ||
| 1043 | |||
| 1044 | function suspend(_: Editor, repl: Repl) { | ||
| 1045 | repl.suspend(); | ||
| 1046 | } | ||
| 1047 | |||
| 1048 | /** what to draw an {@linkcode Editor} into */ | ||
| 1049 | export 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 | |||
| 1065 | export 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 | */ | ||
| 1077 | export 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 | */ | ||
| 1169 | export 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 | |||
| 1397 | type Syntax = Pick<Language, "tokenizer" | "styles" | "incomplete" | "complete">; | ||
| 1398 | |||
| 1399 | const indent = " "; | ||
| 1400 | const menuRows = 6; | ||
| 1401 | const historyLimit = 1000; | ||
| 1402 | const commandLine = /^\/([\w-]+)(?:\s+([\s\S]*))?$/; | ||
| 1403 | const commandWord = /\/([\w-]+)(?!\S)/y; | ||
| 1404 | /** the tokenizer state of a line that is a command */ | ||
| 1405 | const commandState = "\0/"; | ||
| 1406 | |||
| 1407 | let segmenter: Intl.Segmenter | null = null; | ||
| 1408 | |||
| 1409 | /** a `language` where a line that names one of `commands` is not code */ | ||
| 1410 | function 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 | |||
| 1450 | function 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 | |||
| 1473 | function 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 */ | ||
| 1478 | function 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 | |||
| 1489 | function 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 | |||
| 1494 | function lineStart(text: string, offset: number) { | ||
| 1495 | return offset === 0 ? 0 : text.lastIndexOf("\n", offset - 1) + 1; | ||
| 1496 | } | ||
| 1497 | |||
| 1498 | function 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 */ | ||
| 1504 | function 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` */ | ||
| 1517 | function 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 | |||
| 1528 | interface 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 | */ | ||
| 1539 | function 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 | } | ||
| 1558 | const printableAscii = /^[\x20-\x7e]*$/; | ||
| 1559 | |||
| 1560 | interface 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 */ | ||
| 1568 | function 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` */ | ||
| 1595 | function 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 */ | ||
| 1606 | function 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 */ | ||
| 1627 | function 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 | */ | ||
| 1638 | function 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 | |||
| 1658 | function 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 | */ | ||
| 1668 | function 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 | */ | ||
| 1689 | function 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 */ | ||
| 1708 | const 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 */ | ||
| 1710 | const filled = ansi.bg256(237) + ansi.fgBrightWhite; | ||
| 1711 | /** the highlighted option of a menu, and around a matched character */ | ||
| 1712 | const menuChosen = ansi.bgBlue + ansi.fgBrightWhite; | ||
| 1713 | const menuMatch = [ansi.bold + ansi.fgBrightYellow, ansi.resetWeight + ansi.fgBrightWhite] as const; | ||
| 1714 | |||
| 1715 | /** colors for the kinds of token that {@linkcode javascriptTokenizer} produces */ | ||
| 1716 | const 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 | */ | ||
| 1738 | const 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 | |||
| 1819 | const 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 | ); | ||
| 1823 | const literals = new Set(["true", "false", "null", "undefined", "NaN", "Infinity"]); | ||
| 1824 | const closers: Partial<Record<string, string>> = { ")": "(", "]": "[", "}": "{" }; | ||
| 1825 | |||
| 1826 | const whitespace = /\s+/y; | ||
| 1827 | const lineComment = /\/\/[^\n]*/y; | ||
| 1828 | const 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 | ||
| 1831 | const 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; | ||
| 1833 | const quoted = /'(?:[^'\\\n]|\\[\s\S])*'?|"(?:[^"\\\n]|\\[\s\S])*"?/y; | ||
| 1834 | const templateText = /(?:[^`\\$]|\\[\s\S]|\$(?!\{))+/y; | ||
| 1835 | const regexp = /\/(?:[^\/\\\[\n]|\\[^\n]|\[(?:[^\]\\\n]|\\[^\n])*\]?)+(?:\/[\p{ID_Continue}$]*)?/uy; | ||
| 1836 | const punctuation = | ||
| 1837 | />>>=|\.\.\.|===|!==|\*\*=|<<=|>>=|>>>|&&=|\|\|=|\?\?=|=>|==|!=|<=|>=|&&|\|\||\?\?|\?\.(?!\d)|\+\+|--|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<|>>|\*\*|[-+*\/%&|^!~<>=?:;,.@]/y; | ||
| 1838 | |||
| 1839 | const lineTerminator = /\r\n|[\n\r\u2028\u2029]/; | ||
| 1840 | const identifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u; | ||
| 1841 | /** a chain of property accesses, then the name being typed */ | ||
| 1842 | const 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 */ | ||
| 1845 | function 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 | */ | ||
| 1855 | function 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 | */ | ||
| 1874 | const 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 | }; | ||
| 1891 | const bridgeSource = `globalThis[Symbol.for(${JSON.stringify(import.meta.url)})]`; | ||
| 1892 | let taken: unknown; | ||
| 1893 | |||
| 1894 | function 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 */ | ||
| 1922 | interface Remote { | ||
| 1923 | objectId?: string; | ||
| 1924 | unserializableValue?: string; | ||
| 1925 | value?: unknown; | ||
| 1926 | } | ||
| 1927 | |||
| 1928 | /** replies are typed `any`, as their shape depends on the method */ | ||
| 1929 | interface 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 */ | ||
| 1938 | function 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 | |||
| 1986 | import { ASSERT, UNWRAP } from "./assert.ts"; | ||
| 1987 | import * as exceptions from "./exception.ts"; | ||
| 1988 | import * as log from "./log.ts"; | ||
| 1989 | import * as stack from "./log/stack.ts"; | ||
| 1990 | import * as node from "./node.ts"; | ||
| 1991 | import * as progress from "./progress.ts"; | ||
| 1992 | import * as string from "./string.ts"; | ||
| 1993 | import * as ansi from "./string/ansi.ts"; | ||
| 1994 | import * 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` */ |
| 27 | export const resetWeight: string = `\x1b[22m`; | 27 | export const resetWeight: string = `\x1b[22m`; |
| 28 | 28 | ||
| 29 | /** | ||
| 30 | * swaps the foreground and background colors, which reads as a highlight in | ||
| 31 | * any color scheme. reset with `resetInverse`. | ||
| 32 | */ | ||
| 33 | export const inverse: string = `\x1b[7m`; | ||
| 34 | /** resets `inverse` */ | ||
| 35 | export const resetInverse: string = `\x1b[27m`; | ||
| 36 | |||
| 29 | /** resets foreground color */ | 37 | /** resets foreground color */ |
| 30 | export const fgReset: string = `\x1b[39m`; | 38 | export const fgReset: string = `\x1b[39m`; |
| 31 | /** sets the foreground color to black. on light mode terminals, this color may | 39 | /** 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 { |
| 152 | export function cursorLeft(n: number): string { | 160 | export 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. */ | ||
| 164 | export 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. */ |
| 156 | export const startOfLine: string = "\r"; | 166 | export 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 | */ | ||
| 164 | export 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 | } |
| 147 | 372 | ||
| 148 | export function removeUndefinedKeys(obj: unknown): unknown { | 373 | export 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[] = []; |
| 172 | 397 | ||
| 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); | ||
| 174 | 401 | ||
| 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 { |
| 181 | 408 | ||
| 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; | ||
| 184 | 412 | ||
| 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 | } |
| 260 | 498 | ||
| 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 | } |