| 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 | const odd = Object.defineProperty(new Error("odd"), "field", { |
| 947 | enumerable: true, |
| 948 | get: () => { |
| 949 | throw new Error("unreadable"); |
| 950 | }, |
| 951 | }); |
| 952 | evaluating[0]?.reject(odd); |
| 953 | await settle(screen); |
| 954 | assert.deepEqual(linesOf(screen), ["> x", "odd", "> ab"]); |
| 955 | // as does a key binding that throws |
| 956 | screen.type("\tc"); |
| 957 | screen.advance(); |
| 958 | assert.equal(linesOf(screen).at(-1), "> abc"); |
| 959 | ASSERT(linesOf(screen).includes("error: Unexpected null")); |
| 960 | |
| 961 | // closing forgets what was typed ahead |
| 962 | screen.type("\rd\r"); |
| 963 | prompt.close(); |
| 964 | evaluating[1]?.resolve(undefined); |
| 965 | await settle(screen); |
| 966 | assert.deepEqual(evaluating.map((entry) => entry.code), ["x", "abc"]); |
| 967 | screen.cancel(); |
| 968 | }); |
| 969 | |
| 970 | test("an unfinished line continues", () => { |
| 971 | const screen = new testing.MockScreen(); |
| 972 | const { language, evaluating } = manualLanguage(); |
| 973 | using prompt = new repl.Repl({ interface: screen, language, prompt: "> " }); |
| 974 | screen.advance(); |
| 975 | screen.type("f({\r"); |
| 976 | screen.advance(); |
| 977 | assert.equal(screen.terminal.text, "> f({\n…"); |
| 978 | assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 }); |
| 979 | screen.type("a\r})\r"); |
| 980 | screen.advance(); |
| 981 | assert.equal(evaluating[0]?.code, "f({\n a\n})"); |
| 982 | assert.equal(screen.terminal.text, "> f({\n… a\n… })"); |
| 983 | // a blank line is not evaluated at all |
| 984 | prompt.close(); |
| 985 | screen.advance(); |
| 986 | screen.cancel(); |
| 987 | }); |
| 988 | |
| 989 | test("pasted text is not entered line by line", () => { |
| 990 | const screen = new testing.MockScreen(); |
| 991 | const { language, evaluating } = manualLanguage(); |
| 992 | using _ = new repl.Repl({ interface: screen, language, prompt: "> " }); |
| 993 | screen.advance(); |
| 994 | screen.type("\x1b[200~a\r\n\tb\x1b[201~"); |
| 995 | screen.advance(); |
| 996 | assert.equal(screen.terminal.text, "> a\n… b"); |
| 997 | assert.equal(evaluating.length, 0); |
| 998 | screen.type("\x03"); |
| 999 | screen.advance(); |
| 1000 | assert.equal(screen.terminal.text, ">"); |
| 1001 | screen.cancel(); |
| 1002 | }); |
| 1003 | |
| 1004 | test("commands run beside the prompt", async () => { |
| 1005 | const screen = new testing.MockScreen(); |
| 1006 | const calls: string[] = []; |
| 1007 | let finish = () => {}; |
| 1008 | using prompt = new repl.Repl({ |
| 1009 | interface: screen, |
| 1010 | language: null, |
| 1011 | prompt: "> ", |
| 1012 | commands: { |
| 1013 | greet: { |
| 1014 | async execute(args, node) { |
| 1015 | ASSERT(this === prompt); |
| 1016 | calls.push(args); |
| 1017 | node.log.info("hello", args); |
| 1018 | using _ = node.start("counting", { total: 4, value: 1 }); |
| 1019 | await new Promise<void>((resolve) => finish = resolve); |
| 1020 | }, |
| 1021 | }, |
| 1022 | "wait-up": { |
| 1023 | block: true, |
| 1024 | execute: () => new Promise<void>((resolve) => finish = resolve), |
| 1025 | }, |
| 1026 | // names that cannot be typed as a command are never run or offered |
| 1027 | "": { execute: () => void calls.push("nameless") }, |
| 1028 | "gr eet": { execute: () => void calls.push("spaced") }, |
| 1029 | fail: { |
| 1030 | execute() { |
| 1031 | throw new Error("no"); |
| 1032 | }, |
| 1033 | }, |
| 1034 | }, |
| 1035 | }); |
| 1036 | screen.advance(); |
| 1037 | |
| 1038 | // the names of commands come up at the slash, lined up under it |
| 1039 | screen.type("/"); |
| 1040 | screen.advance(); |
| 1041 | assert.equal(screen.terminal.text, "> /\n fail\n greet\n wait-up"); |
| 1042 | assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 }); |
| 1043 | // the first is highlighted, which enter takes before it enters the line |
| 1044 | screen.type("g\r"); |
| 1045 | screen.advance(); |
| 1046 | assert.equal(linesOf(screen).at(-1), "> /greet"); |
| 1047 | assert.deepEqual(calls, []); |
| 1048 | screen.type(" to all \r"); |
| 1049 | await settle(screen, 1); |
| 1050 | assert.deepEqual(calls, ["to all"]); |
| 1051 | const bar = "███ "; |
| 1052 | assert.equal( |
| 1053 | screen.terminal.text, |
| 1054 | ["> /greet to all", ">", "⠋ /greet to all", `└─ ${bar} [1/4] counting`].join("\n"), |
| 1055 | ); |
| 1056 | assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 }); |
| 1057 | |
| 1058 | // the prompt keeps working above the progress display |
| 1059 | screen.type("hi"); |
| 1060 | screen.advance(); |
| 1061 | assert.deepEqual(screen.terminal.cursor, { line: 1, column: 4 }); |
| 1062 | finish(); |
| 1063 | await settle(screen, 1); |
| 1064 | // what the command logged to its node comes out once it is done |
| 1065 | const logged = ["> /greet to all", "[logs from /greet to all]", "info: hello to all"]; |
| 1066 | assert.deepEqual(linesOf(screen), [...logged, "> hi"]); |
| 1067 | |
| 1068 | screen.type("\r"); |
| 1069 | await settle(screen); |
| 1070 | assert.deepEqual(linesOf(screen), [...logged, "> hi", "not a command", ">"]); |
| 1071 | |
| 1072 | // unless the command asked for the prompt to wait |
| 1073 | screen.type("/wait-up\r/fail\r"); |
| 1074 | await settle(screen, 1); |
| 1075 | assert.deepEqual(linesOf(screen).slice(-2), ["> /wait-up", "⠋ /wait-up"]); |
| 1076 | finish(); |
| 1077 | await settle(screen, 1); |
| 1078 | await settle(screen, 1); |
| 1079 | // a failure is reported with its trace |
| 1080 | const lines = linesOf(screen); |
| 1081 | const failed = lines.lastIndexOf("> /fail"); |
| 1082 | assert.deepEqual(lines.slice(failed - 1, failed + 2), ["> /wait-up", "> /fail", "error: no"]); |
| 1083 | assert.equal(lines.at(-1), ">"); |
| 1084 | |
| 1085 | prompt.close(); |
| 1086 | screen.advance(1); |
| 1087 | screen.cancel(); |
| 1088 | }); |
| 1089 | |
| 1090 | test("completions are listed under the prompt", async () => { |
| 1091 | const screen = new testing.MockScreen(); |
| 1092 | const { language, evaluating } = manualLanguage(); |
| 1093 | using prompt = new repl.Repl({ interface: screen, language, prompt: "> " }); |
| 1094 | screen.advance(); |
| 1095 | // asked for with tab |
| 1096 | screen.type("a\t"); |
| 1097 | screen.advance(); |
| 1098 | assert.equal(screen.terminal.text, "> a\n alpha\n alpine"); |
| 1099 | assert.deepEqual(screen.terminal.cursor, { line: 0, column: 3 }); |
| 1100 | // the arrows choose, enter takes the choice, and enter again the line |
| 1101 | screen.type("\x1b[B\r"); |
| 1102 | screen.advance(); |
| 1103 | assert.equal(screen.terminal.text, "> alpine"); |
| 1104 | assert.equal(evaluating.length, 0); |
| 1105 | screen.type("\r"); |
| 1106 | evaluating[0]?.resolve(undefined); |
| 1107 | await settle(screen); |
| 1108 | assert.equal(evaluating[0]?.code, "alpine"); |
| 1109 | |
| 1110 | // or coming up as the language sees fit, here after a colon |
| 1111 | screen.type("x:"); |
| 1112 | screen.advance(); |
| 1113 | assert.deepEqual(linesOf(screen).slice(-4), ["> x:", " alpha", " alpine", " beta"]); |
| 1114 | // an option that is typed out leaves enter to do its usual |
| 1115 | screen.type("beta\r"); |
| 1116 | screen.advance(); |
| 1117 | assert.equal(evaluating[1]?.code, "x:beta"); |
| 1118 | evaluating[1]?.resolve(undefined); |
| 1119 | await settle(screen); |
| 1120 | |
| 1121 | // a history search is shown there as well |
| 1122 | screen.type("\x12al"); |
| 1123 | screen.advance(); |
| 1124 | assert.deepEqual(linesOf(screen).slice(-2), ["> alpine", "search: al"]); |
| 1125 | assert.deepEqual(screen.terminal.cursor, { line: linesOf(screen).length - 2, column: 4 }); |
| 1126 | prompt.close(); |
| 1127 | screen.advance(); |
| 1128 | screen.cancel(); |
| 1129 | }); |
| 1130 | |
| 1131 | test("a command completes what follows its name", async () => { |
| 1132 | const screen = new testing.MockScreen(); |
| 1133 | const ran: string[] = []; |
| 1134 | const word = (args: string) => args.lastIndexOf(" ") + 1; |
| 1135 | using prompt = new repl.Repl({ |
| 1136 | interface: screen, |
| 1137 | language: manualLanguage().language, |
| 1138 | prompt: "> ", |
| 1139 | commands: { |
| 1140 | deploy: { |
| 1141 | execute: (args) => void ran.push(args), |
| 1142 | // the first word is offered unasked, and the ones after it by tab |
| 1143 | complete: (args, asked) => |
| 1144 | asked || !args.includes(" ") ? { from: word(args), options: ["production", "preview", "staging"] } : null, |
| 1145 | }, |
| 1146 | // a function is a command as well, without anything to complete |
| 1147 | plain: (args) => void ran.push("plain " + args), |
| 1148 | }, |
| 1149 | }); |
| 1150 | screen.advance(); |
| 1151 | |
| 1152 | // options are lined up under what they would stand in for |
| 1153 | const under = (column: number, option: string) => " ".repeat(column) + option; |
| 1154 | screen.type("/deploy "); |
| 1155 | screen.advance(); |
| 1156 | assert.deepEqual(linesOf(screen), [ |
| 1157 | "> /deploy", |
| 1158 | under(10, "production"), |
| 1159 | under(10, "preview"), |
| 1160 | under(10, "staging"), |
| 1161 | ]); |
| 1162 | // narrowed by what is typed, loosely, and taken by enter |
| 1163 | screen.type("ag"); |
| 1164 | screen.advance(); |
| 1165 | assert.deepEqual(linesOf(screen), ["> /deploy ag", under(10, "staging")]); |
| 1166 | screen.type("\r x"); |
| 1167 | screen.advance(); |
| 1168 | assert.deepEqual(linesOf(screen), ["> /deploy staging x"]); |
| 1169 | screen.type("\x7fp\t"); |
| 1170 | screen.advance(); |
| 1171 | assert.deepEqual(linesOf(screen), ["> /deploy staging p", under(18, "production"), under(18, "preview")]); |
| 1172 | screen.type("\x1b[B\r\r"); |
| 1173 | await settle(screen, 1); |
| 1174 | assert.deepEqual(ran, ["staging preview"]); |
| 1175 | |
| 1176 | // a command that has nothing to say gets no offers, the language's included |
| 1177 | screen.type("/plain a\t"); |
| 1178 | screen.advance(); |
| 1179 | assert.equal(linesOf(screen).at(-1), "> /plain a"); |
| 1180 | assert.equal(prompt.editor.menu, null); |
| 1181 | screen.type("\r"); |
| 1182 | await settle(screen, 1); |
| 1183 | assert.deepEqual(ran, ["staging preview", "plain a"]); |
| 1184 | prompt.close(); |
| 1185 | screen.advance(); |
| 1186 | screen.cancel(); |
| 1187 | }); |
| 1188 | |
| 1189 | test("a row is kept under the prompt, which work is shown in", async () => { |
| 1190 | const drawn = (text: string, up: number) => |
| 1191 | testing.MockScreen.sync([`\x1b[?7l${text}\x1b[?7h`, ansi.cursorUp(up) + ansi.cursorRight(2)]); |
| 1192 | const asking = "\x1b[?2004h\x1b[?u\x1b[>c"; |
| 1193 | |
| 1194 | const screen = new testing.MockScreen(); |
| 1195 | let finish = () => {}; |
| 1196 | const commands = { wait: { execute: () => new Promise<void>((resolve) => finish = resolve) } }; |
| 1197 | using prompt = new repl.Repl({ interface: screen, language: null, prompt: "> ", commands }); |
| 1198 | screen.expectFrame(0, { stderr: asking + drawn("> \n\n", 2) }); |
| 1199 | screen.type("/wait\r"); |
| 1200 | await settle(screen, 1); |
| 1201 | assert.equal(screen.terminal.text, "> /wait\n>\n⠋ /wait"); |
| 1202 | assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 }); |
| 1203 | finish(); |
| 1204 | await settle(screen, 1); |
| 1205 | prompt.close(); |
| 1206 | screen.advance(); |
| 1207 | screen.cancel(); |
| 1208 | |
| 1209 | const bare = new testing.MockScreen(); |
| 1210 | using none = new repl.Repl({ interface: bare, language: null, prompt: "> ", reserve: 0 }); |
| 1211 | bare.expectFrame(0, { stderr: asking + drawn("> \n", 1) }); |
| 1212 | none.close(); |
| 1213 | bare.advance(); |
| 1214 | bare.cancel(); |
| 1215 | }); |
| 1216 | |
| 1217 | test("the prompt is styled, titled, and filled in as asked for", () => { |
| 1218 | const screen = new testing.MockScreen(); |
| 1219 | screen.resize(16); |
| 1220 | const { language } = manualLanguage(); |
| 1221 | using prompt = new repl.Repl({ |
| 1222 | interface: screen, |
| 1223 | language, |
| 1224 | prompt: "> ", |
| 1225 | style: "lines", |
| 1226 | title: "node", |
| 1227 | placeholder: "ask away", |
| 1228 | }); |
| 1229 | screen.advance(); |
| 1230 | const line = "─".repeat(15); |
| 1231 | assert.equal(screen.terminal.text, ["─┤ node ├──────", "> ask away", line].join("\n")); |
| 1232 | assert.deepEqual(screen.terminal.cursor, { line: 1, column: 2 }); |
| 1233 | screen.type("f(\r"); |
| 1234 | screen.advance(); |
| 1235 | assert.equal(screen.terminal.text, ["─┤ node ├──────", "> f(", "…", line].join("\n")); |
| 1236 | assert.deepEqual(screen.terminal.cursor, { line: 2, column: 4 }); |
| 1237 | prompt.close(); |
| 1238 | screen.advance(); |
| 1239 | screen.cancel(); |
| 1240 | }); |
| 1241 | |
| 1242 | test("keys can be rebound", () => { |
| 1243 | const screen = new testing.MockScreen(); |
| 1244 | const { language } = manualLanguage(); |
| 1245 | const seen: string[] = []; |
| 1246 | using prompt = new repl.Repl({ |
| 1247 | interface: screen, |
| 1248 | language, |
| 1249 | prompt: "> ", |
| 1250 | keymap: { |
| 1251 | "ctrl+a": null, |
| 1252 | "ctrl+e": "moveLineStart", |
| 1253 | "f1": (editor, self) => { |
| 1254 | ASSERT(self === prompt); |
| 1255 | seen.push(editor.text); |
| 1256 | }, |
| 1257 | }, |
| 1258 | }); |
| 1259 | screen.advance(); |
| 1260 | screen.type("abc\x01\x05x\x1bOP"); |
| 1261 | assert.deepEqual(seen, ["xabc"]); |
| 1262 | prompt.close(); |
| 1263 | screen.advance(); |
| 1264 | screen.cancel(); |
| 1265 | }); |
| 1266 | |
| 1267 | test("the prompt steps aside for a widget that takes the keyboard", () => { |
| 1268 | const screen = new testing.MockScreen(); |
| 1269 | const { language } = manualLanguage(); |
| 1270 | using prompt = new repl.Repl({ interface: screen, language, prompt: "> " }); |
| 1271 | screen.advance(); |
| 1272 | screen.type("abc"); |
| 1273 | const seen: string[] = []; |
| 1274 | const question = screen.startWidget({ |
| 1275 | format: () => "sure? (y/n)", |
| 1276 | input: (event) => seen.push(event.key), |
| 1277 | }); |
| 1278 | screen.advance(); |
| 1279 | assert.equal(screen.terminal.text, "sure? (y/n)"); |
| 1280 | screen.type("y"); |
| 1281 | question?.stop(); |
| 1282 | screen.advance(); |
| 1283 | assert.deepEqual(seen, ["y"]); |
| 1284 | assert.equal(screen.terminal.text, "> abc"); |
| 1285 | |
| 1286 | // ctrl+l clears, and ctrl+d on an empty line closes |
| 1287 | screen.writeOutput("some logs\n"); |
| 1288 | screen.type("\x0c"); |
| 1289 | screen.advance(); |
| 1290 | assert.equal(screen.terminal.text, "> abc"); |
| 1291 | assert.deepEqual(screen.terminal.cursor, { line: 0, column: 5 }); |
| 1292 | screen.type("\x01\x04\x04\x04\x04"); |
| 1293 | screen.advance(); |
| 1294 | ASSERT(!screen.reading); |
| 1295 | assert.equal(prompt.editor.text, ""); |
| 1296 | screen.cancel(); |
| 1297 | }); |
| 1298 | |
| 1299 | test("history is kept in a file", async () => { |
| 1300 | const directory = fs.mkdtempSync(path.join(os.tmpdir(), "repl-test-")); |
| 1301 | const file = path.join(directory, "nested", "history"); |
| 1302 | try { |
| 1303 | const language = { ...manualLanguage().language, eval: async () => {} }; |
| 1304 | // the second time around, the first line is recalled and added to |
| 1305 | for (const lines of ["one\rf({\r1\r})\r", "\x1b[A\x1b[A\x1b[A\x1b[Atwo\r"]) { |
| 1306 | const screen = new testing.MockScreen(); |
| 1307 | using _ = new repl.Repl({ interface: screen, language, history: file }); |
| 1308 | screen.advance(); |
| 1309 | screen.type(lines); |
| 1310 | await settle(screen); |
| 1311 | } |
| 1312 | assert.equal(fs.readFileSync(file, "utf-8"), "\"one\"\n\"f({\\n 1\\n})\"\n\"onetwo\"\n"); |
| 1313 | } finally { |
| 1314 | fs.rmSync(directory, { recursive: true }); |
| 1315 | } |
| 1316 | }); |
| 1317 | }); |
| 1318 | |
| 1319 | /** |
| 1320 | * two ends that pass messages the way a process and its child do: later, and |
| 1321 | * as JSON. the first can also lose the other. |
| 1322 | */ |
| 1323 | function channels(): [repl.Channel & { disconnect(): void }, repl.Channel] { |
| 1324 | let connected = true; |
| 1325 | const listeners = [new Map<(message?: unknown) => void, string>(), new Map<(message?: unknown) => void, string>()]; |
| 1326 | const emit = (end: number, event: string, message?: unknown) => { |
| 1327 | for (const [listener, wanted] of [...UNWRAP(listeners[end])]) { |
| 1328 | if (wanted === event) listener(message); |
| 1329 | } |
| 1330 | }; |
| 1331 | const end = (mine: number) => ({ |
| 1332 | send(message: unknown, callback?: (error: Error | null) => void) { |
| 1333 | const copy = JSON.stringify(message); |
| 1334 | queueMicrotask(() => { |
| 1335 | callback?.(connected ? null : new Error("channel closed")); |
| 1336 | if (connected) emit(1 - mine, "message", JSON.parse(copy)); |
| 1337 | }); |
| 1338 | }, |
| 1339 | on: (event: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).set(listener, event), |
| 1340 | off: (_: string, listener: (message?: unknown) => void) => void UNWRAP(listeners[mine]).delete(listener), |
| 1341 | disconnect() { |
| 1342 | connected = false; |
| 1343 | emit(mine, "disconnect"); |
| 1344 | }, |
| 1345 | }); |
| 1346 | return [end(0), end(1)]; |
| 1347 | } |
| 1348 | |
| 1349 | describe("remote", () => { |
| 1350 | const language = repl.javascript({ globals: { replTestRemote: { answer: 42 } } }); |
| 1351 | /** what evaluating `code` at the other end prints here, as text without colors */ |
| 1352 | const printed = async (far: repl.Language, code: string) => { |
| 1353 | const lines: string[] = []; |
| 1354 | await far.eval(code, (value) => lines.push(ansi.strip(util.inspect(value)))); |
| 1355 | return lines; |
| 1356 | }; |
| 1357 | |
| 1358 | test("evaluates where the language is served, and prints here", async () => { |
| 1359 | const [here, there] = channels(); |
| 1360 | using _ = repl.serve(language, there); |
| 1361 | const far = repl.remote(here); |
| 1362 | assert.deepEqual(await printed(far, "replTestRemote"), ["{ answer: 42 }"]); |
| 1363 | assert.deepEqual(await printed(far, "await Promise.resolve('text')"), ["'text'"]); |
| 1364 | assert.deepEqual(await printed(far, "undefined"), []); |
| 1365 | // what is thrown there is reported from there, as it has the trace |
| 1366 | const [report] = await printed(far, "function replTestRemoteThrow() { null.x }\nreplTestRemoteThrow()"); |
| 1367 | ASSERT(report?.includes("reading 'x'") && report.includes("replTestRemoteThrow"), report); |
| 1368 | // the rest of the language needs no values, and stays here |
| 1369 | assert.equal(far.tokenizer, language.tokenizer); |
| 1370 | ASSERT(far.incomplete?.("f(", "(r")); |
| 1371 | }); |
| 1372 | |
| 1373 | test("completes from there, in the menu of an editor here", async () => { |
| 1374 | const [here, there] = channels(); |
| 1375 | using _ = repl.serve(language, there); |
| 1376 | const editor = new repl.Editor(repl.remote(here)); |
| 1377 | await editor.run(() => editor.type("replTestRemote.")); |
| 1378 | assert.deepEqual(editor.menu?.options.slice(0, 2), ["answer", "__defineGetter__"]); |
| 1379 | await editor.run(() => editor.type("ans")); |
| 1380 | press(editor, "tab"); |
| 1381 | assert.equal(editor.text, "replTestRemote.answer"); |
| 1382 | }); |
| 1383 | |
| 1384 | test("messages that are not its own pass by, and a lost process is said to be", async () => { |
| 1385 | const [here, there] = channels(); |
| 1386 | using stop = repl.serve(language, there); |
| 1387 | const far = repl.remote(here); |
| 1388 | UNWRAP(here.send)({ other: "traffic" }, () => {}); |
| 1389 | UNWRAP(there.send)({ other: "traffic" }, () => {}); |
| 1390 | assert.deepEqual(await printed(far, "1 + 1"), ["2"]); |
| 1391 | |
| 1392 | // what was being evaluated when the process is lost is given up on |
| 1393 | const gone = ["the process that was asked is gone"]; |
| 1394 | const waiting = printed(far, "await new Promise((resolve) => globalThis.replTestRemoteDone = resolve)"); |
| 1395 | await new Promise((resolve) => setImmediate(resolve)); |
| 1396 | here.disconnect(); |
| 1397 | assert.deepEqual(await waiting, gone); |
| 1398 | Reflect.get(globalThis, "replTestRemoteDone")(); |
| 1399 | // and so is what is asked of it afterwards |
| 1400 | assert.deepEqual(await printed(far, "1"), gone); |
| 1401 | assert.equal(await far.complete?.("process.", 8, false, "punctuation"), null); |
| 1402 | stop(); |
| 1403 | }); |
| 1404 | }); |
| 1405 | |
| 1406 | describe("javascript", () => { |
| 1407 | const language = repl.javascript({ globals: { replTestGlobal: { answer: 42 } } }); |
| 1408 | /** the values that evaluating `code` prints */ |
| 1409 | const printed = async (code: string) => { |
| 1410 | const values: unknown[] = []; |
| 1411 | await language.eval(code, (value) => values.push(value)); |
| 1412 | return values; |
| 1413 | }; |
| 1414 | |
| 1415 | test("evaluates in the global scope, as a console would", async () => { |
| 1416 | assert.deepEqual(await printed("1 + 1"), [2]); |
| 1417 | assert.deepEqual(await printed("replTestGlobal.answer"), [42]); |
| 1418 | assert.equal((await printed("globalThis"))[0], globalThis); |
| 1419 | assert.deepEqual(await printed("({ a: [1, { b: 2 }] })"), [{ a: [1, { b: 2 }] }]); |
| 1420 | |
| 1421 | // declarations persist, and can be made again |
| 1422 | assert.deepEqual(await printed("let replTestLet = 5"), []); |
| 1423 | assert.deepEqual(await printed("const replTestConst = replTestLet * 2; replTestConst"), [10]); |
| 1424 | assert.deepEqual(await printed("let replTestLet = 7; replTestLet"), [7]); |
| 1425 | assert.deepEqual(await printed("await Promise.resolve(replTestLet + 1)"), [8]); |
| 1426 | assert.deepEqual(await printed("const replTestAwait = await 3; replTestAwait"), [3]); |
| 1427 | }); |
| 1428 | |
| 1429 | test("values come back as themselves", async () => { |
| 1430 | assert.deepEqual(await printed("undefined"), []); |
| 1431 | assert.deepEqual( |
| 1432 | await printed("[null, 0 / 0, -0, -1 / 0, 2n ** 70n, Symbol.iterator, 'text']"), |
| 1433 | [[null, NaN, -0, -Infinity, 2n ** 70n, Symbol.iterator, "text"]], |
| 1434 | ); |
| 1435 | for (const code of ["null", "0 / 0", "-0", "-1 / 0", "2n ** 70n", "Symbol.iterator", "'text'"]) { |
| 1436 | assert.deepEqual(await printed(code), [(0, eval)(code)]); |
| 1437 | } |
| 1438 | // a promise that was not awaited is the value, not what it resolves to |
| 1439 | const [promise] = await printed("new Promise(() => {})"); |
| 1440 | ASSERT(promise instanceof Promise); |
| 1441 | }); |
| 1442 | |
| 1443 | test("errors come back as themselves", async () => { |
| 1444 | const error = await printed("function replTestThrow() { null.x }\nreplTestThrow()") |
| 1445 | .catch((error) => error); |
| 1446 | ASSERT(error instanceof TypeError); |
| 1447 | // the trace ends where the entered code does |
| 1448 | assert.match(UNWRAP(error.stack), /^TypeError: .*\n at replTestThrow \(.*\)$/); |
| 1449 | const late = await printed("await null; throw new Error('late')").catch((error) => error); |
| 1450 | assert.equal(late.stack, "Error: late"); |
| 1451 | |
| 1452 | await assert.rejects(printed("await Promise.reject(new RangeError('late'))"), RangeError); |
| 1453 | await assert.rejects(printed("x = ;"), SyntaxError); |
| 1454 | assert.equal(await printed("throw 5").catch((error) => error), 5); |
| 1455 | }); |
| 1456 | |
| 1457 | test("import() works, though the inspector's scripts cannot", async () => { |
| 1458 | assert.deepEqual(await printed("(await import('node:os')).EOL"), [os.EOL]); |
| 1459 | // relative to the working directory |
| 1460 | assert.deepEqual( |
| 1461 | await printed("const { clamp } = await import(\"./lib/math.ts\"); clamp(7, 0, 5)"), |
| 1462 | [5], |
| 1463 | ); |
| 1464 | assert.deepEqual(await printed("(await import /* c */ ('node:os')).EOL"), [os.EOL]); |
| 1465 | // a property or a method of that name is left alone |
| 1466 | assert.deepEqual(await printed("({ import: (x) => x }).import(4)"), [4]); |
| 1467 | assert.deepEqual(await printed("({ import(x) { return x } }).import(5)"), [5]); |
| 1468 | assert.deepEqual(await printed("class ReplTestImport { static import(x) { return x } }"), []); |
| 1469 | }); |
| 1470 | |
| 1471 | test("knows when a line wants another", () => { |
| 1472 | const incomplete = (code: string) => UNWRAP(language.incomplete)(code, stateAfter(code)); |
| 1473 | for ( |
| 1474 | const code of [ |
| 1475 | "f(", |
| 1476 | "x = {", |
| 1477 | "`a${", |
| 1478 | "`a", |
| 1479 | "/* a", |
| 1480 | "if (x)", |
| 1481 | "x =", |
| 1482 | "1 +", |
| 1483 | "a.", |
| 1484 | "class A", |
| 1485 | "do x", |
| 1486 | "await f(1,\n2) +", |
| 1487 | "if (x) {\n} else", |
| 1488 | ] |
| 1489 | ) { |
| 1490 | ASSERT(incomplete(code), code); |
| 1491 | } |
| 1492 | for ( |
| 1493 | const code of [ |
| 1494 | "1 + 1", |
| 1495 | "f()", |
| 1496 | "}", |
| 1497 | "f(})", |
| 1498 | "(]", |
| 1499 | "'abc", |
| 1500 | "x = ;", |
| 1501 | "const b = await 1", |
| 1502 | "x // c", |
| 1503 | "/* a */", |
| 1504 | "if (x) {\n}", |
| 1505 | ] |
| 1506 | ) { |
| 1507 | ASSERT(!incomplete(code), code); |
| 1508 | } |
| 1509 | // the lexer takes these slashes for division and so sees brackets open, |
| 1510 | // but a line that parses can always be entered |
| 1511 | const misread = ["if (x) /\\(/.test(s)", "while (m) /`/.test(s)", "f({} / 2)"]; |
| 1512 | for (const code of misread) { |
| 1513 | ASSERT(stateAfter(code).length > 1 && !incomplete(code), code); |
| 1514 | } |
| 1515 | // lines are counted the way the parser counts them |
| 1516 | ASSERT(incomplete("'a\u{2028}' +")); |
| 1517 | }); |
| 1518 | |
| 1519 | test("completes names and properties", async () => { |
| 1520 | const complete = (code: string, asked = true) => { |
| 1521 | const kind = repl.tokenize(language.tokenizer, code).at(-1)?.kind ?? ""; |
| 1522 | const found = UNWRAP(language.complete)(code, code.length, asked, kind); |
| 1523 | // it is in this process, so there is nothing to wait for |
| 1524 | ASSERT(!(found instanceof Promise)); |
| 1525 | return found; |
| 1526 | }; |
| 1527 | /** what is on offer once `keys` are pressed, or the text if nothing is */ |
| 1528 | const offered = (...keys: string[]) => { |
| 1529 | const editor = new repl.Editor(language); |
| 1530 | press(editor, ...keys); |
| 1531 | return editor.menu?.options ?? editor.text; |
| 1532 | }; |
| 1533 | |
| 1534 | // members come up unasked, an object's own before those it inherits |
| 1535 | assert.deepEqual(offered("process.ver").slice(0, 2), ["version", "versions"]); |
| 1536 | assert.equal(offered("f(Math . ma")[0], "max"); |
| 1537 | assert.equal(offered("[...Math.ma")[0], "max"); |
| 1538 | assert.equal(complete("replTestGlobal?.answer.toFi")?.from, 23); |
| 1539 | assert.equal(offered("replTestGlobal?.answer.toFi", "tab"), "replTestGlobal?.answer.toFixed"); |
| 1540 | assert.deepEqual(UNWRAP(complete("replTestGlobal.")).options.slice(0, 2), ["answer", "__defineGetter__"]); |
| 1541 | // names in scope do not, until asked for |
| 1542 | assert.equal(offered("x = proc"), "x = proc"); |
| 1543 | assert.equal(offered("x = proc", "tab"), "x = process"); |
| 1544 | ASSERT(UNWRAP(complete("awa")).options.includes("await")); |
| 1545 | ASSERT(UNWRAP(complete("globalThis.")).options.includes("Array")); |
| 1546 | |
| 1547 | // only chains of names are evaluated |
| 1548 | assert.equal(complete("[]."), null); |
| 1549 | assert.equal(complete("'text'.le"), null); |
| 1550 | assert.equal(complete("f().process.ar"), null); |
| 1551 | assert.equal(complete("1 + "), null); |
| 1552 | assert.deepEqual(complete("replTestMissing.x"), { from: 16, options: [] }); |
| 1553 | // nor is there anything to complete in text that is not code |
| 1554 | assert.equal(complete("'see process.ar"), null); |
| 1555 | assert.equal(complete("x // process.ar"), null); |
| 1556 | assert.deepEqual(offered("`${process.ar").slice(0, 3), ["arch", "argv", "argv0"]); |
| 1557 | |
| 1558 | // names declared at the prompt are known |
| 1559 | await printed("class ReplTestClass {}"); |
| 1560 | assert.equal(offered("new ReplTestCl", "tab"), "new ReplTestClass"); |
| 1561 | |
| 1562 | // a getter is not run to find out what it would return |
| 1563 | let ran = false; |
| 1564 | Object.assign(globalThis, { |
| 1565 | replTestGetter: { |
| 1566 | get value() { |
| 1567 | ran = true; |
| 1568 | return {}; |
| 1569 | }, |
| 1570 | }, |
| 1571 | }); |
| 1572 | assert.deepEqual(offered("replTestGetter.va"), ["value", "valueOf"]); |
| 1573 | assert.deepEqual(complete("replTestGetter.value."), { from: 21, options: [] }); |
| 1574 | ASSERT(!ran); |
| 1575 | }); |
| 1576 | }); |
| 1577 | |
| 1578 | import assert from "node:assert/strict"; |
| 1579 | import * as fs from "node:fs"; |
| 1580 | import * as os from "node:os"; |
| 1581 | import * as path from "node:path"; |
| 1582 | import * as util from "node:util"; |
| 1583 | import { describe, test } from "vitest"; |
| 1584 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1585 | import * as repl from "./repl.ts"; |
| 1586 | import * as ansi from "./string/ansi.ts"; |
| 1587 | import * as testing from "./testing.ts"; |