| 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 | * |
| 23 | * import * as repl from "@clo/lib/repl"; |
| 24 | * ``` |
| 25 | * |
| 26 | * @module |
| 27 | */ |
| 28 | |
| 29 | /** options for {@linkcode Repl} */ |
| 30 | export interface Options { |
| 31 | /** |
| 32 | * what the prompt evaluates when the line is not a command. `null` |
| 33 | * disables arbitrary code execution, leaving only commands. |
| 34 | * @default `javascript()` |
| 35 | */ |
| 36 | language?: Language | null; |
| 37 | /** |
| 38 | * run by entering their name after a slash, as in `/name args`. a function |
| 39 | * is a command that is nothing more than its `execute`. |
| 40 | */ |
| 41 | commands?: Record<string, Command | CommandExecute>; |
| 42 | /** @default a blue `> ` */ |
| 43 | prompt?: string; |
| 44 | /** @default "plain" */ |
| 45 | style?: Style; |
| 46 | /** named in the upper edge of a {@linkcode Style} that has one */ |
| 47 | title?: string; |
| 48 | /** one line that is shown in place of the text while there is none */ |
| 49 | placeholder?: string; |
| 50 | /** |
| 51 | * rows kept under the prompt, so that the first of what comes and goes |
| 52 | * there, like a unit of work or a menu, is shown without moving anything. |
| 53 | * @default 1 |
| 54 | */ |
| 55 | reserve?: number; |
| 56 | /** a file to remember entered lines in, across runs of the program */ |
| 57 | history?: string; |
| 58 | /** layered over {@linkcode keymap}. `null` unbinds a key. */ |
| 59 | keymap?: Record<string, Binding | null>; |
| 60 | /** @default `log` */ |
| 61 | interface?: Host; |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * how the prompt is set apart from what is around it: not at all, by a line |
| 66 | * above and below, or by a background that takes the place of those lines. |
| 67 | */ |
| 68 | export type Style = "plain" | "lines" | "filled"; |
| 69 | |
| 70 | export type CommandExecute = (this: Repl, args: string, node: progress.Node) => Promise<void> | void; |
| 71 | export interface Command { |
| 72 | /** |
| 73 | * `args` is what follows the command name. `node` is the entered line as a |
| 74 | * unit of work, shown under the prompt until the command is done: what it |
| 75 | * logs and starts is kept with it. |
| 76 | */ |
| 77 | execute: CommandExecute; |
| 78 | /** wait for `execute` to complete before giving control back to input */ |
| 79 | block?: boolean; |
| 80 | /** |
| 81 | * what could stand in for the end of `args`, which is what follows the |
| 82 | * command name up to the cursor. `from` is an offset into it. this is |
| 83 | * called like {@linkcode Language.complete}: as text is typed, to bring |
| 84 | * up what is worth offering unasked, and with `asked` once tab is pressed |
| 85 | * or the menu is up. |
| 86 | */ |
| 87 | complete?: (args: string, asked: boolean) => Completion | null; |
| 88 | } |
| 89 | |
| 90 | /** the parts of a widget host that a {@linkcode Repl} uses */ |
| 91 | export type Host = Pick< |
| 92 | log.WidgetHost, |
| 93 | "startWidget" | "writeOutput" | "writeError" | "getDrawLock" | "delay" | "now" |
| 94 | >; |
| 95 | |
| 96 | /** |
| 97 | * what a prompt speaks. |
| 98 | * |
| 99 | * a language that is only used to {@linkcode highlight} needs just |
| 100 | * `tokenizer` and `styles`. |
| 101 | */ |
| 102 | export interface Language { |
| 103 | tokenizer: Tokenizer; |
| 104 | /** an ANSI style for each {@link Token.kind|kind of token} that has one */ |
| 105 | styles: Record<string, string>; |
| 106 | /** |
| 107 | * evaluate an entered line, passing results to `print`, which formats |
| 108 | * values the way `console.log` would. the prompt is hidden until the |
| 109 | * promise settles. |
| 110 | */ |
| 111 | eval(code: string, print: (value: unknown) => void): Promise<void>; |
| 112 | /** |
| 113 | * whether pressing enter should continue `code` on a new line instead of |
| 114 | * evaluating it. `state` is the tokenizer's, at the end of `code`. |
| 115 | */ |
| 116 | incomplete?(code: string, state: string): boolean; |
| 117 | /** |
| 118 | * everything that could stand in for `code` between `from` and `cursor`, |
| 119 | * which the editor narrows down to what the text there loosely matches. |
| 120 | * this is called as text is typed, to bring up a menu wherever there is |
| 121 | * something worth offering unasked, like the members of an object. once |
| 122 | * the menu is up, or when it is `asked` for by key, offer all there is. |
| 123 | * `kind` is that of the token which the cursor is in or right after. an |
| 124 | * answer that is promised is shown once it is there, unless a key was |
| 125 | * pressed since. |
| 126 | */ |
| 127 | complete?( |
| 128 | code: string, |
| 129 | cursor: number, |
| 130 | asked: boolean, |
| 131 | kind: string, |
| 132 | ): Completion | null | Promise<Completion | null>; |
| 133 | } |
| 134 | |
| 135 | export interface Completion { |
| 136 | from: number; |
| 137 | options: string[]; |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * a lexer that can resume from any token boundary, which is what lets an |
| 142 | * {@linkcode Editor} relex only the tokens around an edit. |
| 143 | */ |
| 144 | export interface Tokenizer { |
| 145 | /** the state at the start of the text */ |
| 146 | start: string; |
| 147 | /** |
| 148 | * lex the one token at `offset`, which is before the end of `text`. |
| 149 | * anything this needs to know about the text before `offset` must come |
| 150 | * from `state`, the state of the previous token. it may look ahead |
| 151 | * through the token after this one, but not further. |
| 152 | */ |
| 153 | next(text: string, offset: number, state: string): Token; |
| 154 | } |
| 155 | |
| 156 | export interface Token { |
| 157 | /** where the token ends. tokens cover the text without gaps. */ |
| 158 | end: number; |
| 159 | /** names an entry in {@linkcode Language.styles} */ |
| 160 | kind: string; |
| 161 | /** states are compared by value to find where an edit stops mattering */ |
| 162 | state: string; |
| 163 | } |
| 164 | |
| 165 | /** options for {@linkcode javascript} */ |
| 166 | export interface JavascriptOptions { |
| 167 | /** |
| 168 | * values for the evaluated code to refer to by name. these are defined on |
| 169 | * `globalThis`, as code is evaluated in the program's own global scope. |
| 170 | */ |
| 171 | globals?: Record<string, unknown>; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * javascript, which is what a {@linkcode Repl} speaks unless told otherwise. |
| 176 | * in node.js, code is evaluated through the V8 inspector the same way the |
| 177 | * Chrome DevTools console does it: top-level `await` works, `let` and `const` |
| 178 | * persist between lines and can be redeclared, and the members of an object |
| 179 | * are completed by evaluating it with side effects forbidden. other runtimes |
| 180 | * get a plain `eval`. |
| 181 | * |
| 182 | * ```ts |
| 183 | * // on its own, the language is enough to color source code with |
| 184 | * console.info(repl.highlight(repl.javascript(), "const x = `a${1}`;")); |
| 185 | * ``` |
| 186 | */ |
| 187 | export function javascript({ globals = {} }: JavascriptOptions = {}): Language { |
| 188 | for (const [name, value] of Object.entries(globals)) { |
| 189 | Object.defineProperty(globalThis, name, { |
| 190 | value, |
| 191 | writable: true, |
| 192 | configurable: true, |
| 193 | }); |
| 194 | } |
| 195 | |
| 196 | // connected once there is code to look at, as coloring needs none of it |
| 197 | let connection: Inspector | null | undefined; |
| 198 | const inspector = () => connection === undefined ? connection = connect() : connection; |
| 199 | // what was last completed the members of. these are found once, when the |
| 200 | // dot is typed, and not again for each letter of the name after it. |
| 201 | let members: { of: string; names: string[] } | null = null; |
| 202 | |
| 203 | return { |
| 204 | tokenizer: javascriptTokenizer, |
| 205 | styles: javascriptStyles, |
| 206 | |
| 207 | incomplete(code, state) { |
| 208 | // a closing bracket without its opener is not fixed by more lines |
| 209 | if (state[0] === "!") return false; |
| 210 | const session = inspector(); |
| 211 | if (!session) return state.length > 1; |
| 212 | |
| 213 | // the parser is the judge, since the lexer can misread what a slash |
| 214 | // means. code that parses up to a brace it did not open wanted more. |
| 215 | const reply = session.ask("Runtime.evaluate", { |
| 216 | expression: `(async()=>{\n${code}\n})`, |
| 217 | objectGroup: "parse", |
| 218 | silent: true, |
| 219 | }); |
| 220 | session.ask("Runtime.releaseObjectGroup", { objectGroup: "parse" }); |
| 221 | const details = reply?.exceptionDetails; |
| 222 | if (details?.exception?.className !== "SyntaxError") return false; |
| 223 | // an unclosed comment is reported where it opens instead |
| 224 | return details.lineNumber === code.split(lineTerminator).length + 1 |
| 225 | || state.at(-2) === "*"; |
| 226 | }, |
| 227 | |
| 228 | async eval(code, print) { |
| 229 | members = null; |
| 230 | const session = inspector(); |
| 231 | let value: unknown; |
| 232 | if (session) { |
| 233 | const reply = await session.evaluate({ |
| 234 | expression: bridgeImports(code), |
| 235 | replMode: true, |
| 236 | awaitPromise: true, |
| 237 | objectGroup: "eval", |
| 238 | }); |
| 239 | try { |
| 240 | if (reply.exceptionDetails) { |
| 241 | const error = session.take(reply.exceptionDetails.exception); |
| 242 | if (error instanceof Error && error.stack) { |
| 243 | // what is below the entered code is how it got evaluated, and |
| 244 | // the frame of the entered line itself has no source to show |
| 245 | const trace = error.stack |
| 246 | .replace(/\n at Session\.post \(node:inspector[^]*$/, "") |
| 247 | .replace(/\n at (?:async )?<anonymous>:\d+:\d+$/, ""); |
| 248 | Reflect.set(error, "stack", trace); |
| 249 | } |
| 250 | throw error; |
| 251 | } |
| 252 | value = session.take(reply.result); |
| 253 | } finally { |
| 254 | session.ask("Runtime.releaseObjectGroup", { objectGroup: "eval" }); |
| 255 | } |
| 256 | } else { |
| 257 | value = (0, eval)(code); |
| 258 | } |
| 259 | |
| 260 | // a promise is a value like any other, and is not waited for. nothing |
| 261 | // else will handle its rejection though, and an unhandled one ends |
| 262 | // the process. |
| 263 | if (value instanceof Promise) value.catch(log.error); |
| 264 | if (value !== undefined) print(value); |
| 265 | }, |
| 266 | |
| 267 | complete(code, cursor, asked, kind) { |
| 268 | if (kind === "string" || kind === "comment" || kind === "regexp") return null; |
| 269 | const before = code.slice(Math.max(0, cursor - 256), cursor); |
| 270 | const [, chain = "", partial = ""] = UNWRAP(completable.exec(before)); |
| 271 | const from = cursor - partial.length; |
| 272 | // a member of anything but a chain of names would need evaluating it |
| 273 | const owner = code.slice(0, from - chain.length).trimEnd(); |
| 274 | if ((!chain && !partial) || /[^.]\.$/.test(owner)) return null; |
| 275 | // the names in scope are too many to bring up without being asked |
| 276 | if (!chain && !asked) return null; |
| 277 | |
| 278 | let names: string[]; |
| 279 | if (chain) { |
| 280 | const of = chain.replace(/\s*\??\.\s*$/, ""); |
| 281 | if (members?.of !== of || partial === "") { |
| 282 | // getters may run, so this is only done where side effects are caught |
| 283 | const reply = inspector()?.ask("Runtime.evaluate", { |
| 284 | expression: `(${propertyNames})(${of})`, |
| 285 | throwOnSideEffect: true, |
| 286 | returnByValue: true, |
| 287 | silent: true, |
| 288 | timeout: 100, |
| 289 | }); |
| 290 | members = { of, names: reply?.exceptionDetails ? [] : reply?.result?.value ?? [] }; |
| 291 | } |
| 292 | names = members.names; |
| 293 | } else { |
| 294 | names = [ |
| 295 | ...keywords, |
| 296 | ...literals, |
| 297 | ...propertyNames(globalThis), |
| 298 | ...inspector()?.ask("Runtime.globalLexicalScopeNames", {})?.names ?? [], |
| 299 | ].sort(); |
| 300 | } |
| 301 | |
| 302 | const options = new Set(names.filter((name) => identifier.test(name))); |
| 303 | return { from, options: [...options] }; |
| 304 | }, |
| 305 | }; |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * what {@linkcode serve} and {@linkcode remote} talk over: either end of the |
| 310 | * channel between a node.js process and a child that it started with `"ipc"` |
| 311 | * among its `stdio`, as `fork` does. that is `process` in the child, and |
| 312 | * the `ChildProcess` in its parent. the messages are JSON under a key of |
| 313 | * their own, which leaves the channel free to carry others. |
| 314 | */ |
| 315 | export interface Channel { |
| 316 | send?(message: unknown, callback: (error: Error | null) => void): unknown; |
| 317 | on(event: "message", listener: (message: unknown) => void): unknown; |
| 318 | on(event: "disconnect", listener: () => void): unknown; |
| 319 | off(event: "message", listener: (message: unknown) => void): unknown; |
| 320 | off(event: "disconnect", listener: () => void): unknown; |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * let the process at the other end of `channel` evaluate and complete code |
| 325 | * in this one, by having its prompt speak {@linkcode remote}. values are |
| 326 | * formatted here, where they are, and arrive there as text. |
| 327 | * |
| 328 | * ```ts |
| 329 | * // in a server that a control process started with `fork` |
| 330 | * repl.serve(repl.javascript({ globals: { server } }), process); |
| 331 | * ``` |
| 332 | */ |
| 333 | export function serve(language: Language, channel: Channel): ts.Dispose { |
| 334 | const listener = (message: unknown) => { |
| 335 | const request = unpack<Request>(message); |
| 336 | if (!request) return; |
| 337 | const { id } = request; |
| 338 | |
| 339 | // an answer that cannot be sent has nobody left to hear of it |
| 340 | const reply = (reply: Reply) => post(channel, reply, () => {}); |
| 341 | |
| 342 | if ("complete" in request) { |
| 343 | Promise.resolve() |
| 344 | .then(() => language.complete?.(...request.complete) ?? null) |
| 345 | .catch(() => null) |
| 346 | .then((found) => reply({ id, found })); |
| 347 | return; |
| 348 | } |
| 349 | language.eval(request.eval, (value) => reply({ id, print: inspect(value) })) |
| 350 | .then(() => null, report) |
| 351 | .then((failed) => reply({ id, failed })); |
| 352 | }; |
| 353 | channel.on("message", listener); |
| 354 | return ts.defer(() => void channel.off("message", listener)); |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * the language of the process at the other end of `channel`, which |
| 359 | * {@link serve|serves} it. code is colored by `language`, and judged to be |
| 360 | * complete by it, in this process. evaluating and completing it is theirs. |
| 361 | * |
| 362 | * ```ts |
| 363 | * const server = child_process.fork("server.ts"); |
| 364 | * new repl.Repl({ language: repl.remote(server) }); |
| 365 | * ``` |
| 366 | */ |
| 367 | export function remote(channel: Channel, language: Language = javascript()): Language { |
| 368 | let requests = 0; |
| 369 | /** send `request`, and pass on what comes back for it until `receive` says that was all */ |
| 370 | const ask = (request: object, receive: (reply: Reply) => boolean) => |
| 371 | new Promise<void>((resolve, reject) => { |
| 372 | const id = requests += 1; |
| 373 | const settle = (settled: () => void) => { |
| 374 | channel.off("message", onMessage); |
| 375 | channel.off("disconnect", onDisconnect); |
| 376 | settled(); |
| 377 | }; |
| 378 | const onMessage = (message: unknown) => { |
| 379 | const reply = unpack<Reply>(message); |
| 380 | if (reply?.id === id && receive(reply)) settle(resolve); |
| 381 | }; |
| 382 | const onDisconnect = () => settle(() => reject(new Error("the process that was asked is gone"))); |
| 383 | |
| 384 | channel.on("message", onMessage); |
| 385 | channel.on("disconnect", onDisconnect); |
| 386 | post(channel, { id, ...request }, (error) => { |
| 387 | if (error) onDisconnect(); |
| 388 | }); |
| 389 | }); |
| 390 | |
| 391 | return { |
| 392 | ...language, |
| 393 | eval: (code, print) => |
| 394 | ask({ eval: code }, (reply) => { |
| 395 | if ("print" in reply) print(formatted(reply.print)); |
| 396 | if ("failed" in reply && reply.failed !== null) print(formatted(reply.failed)); |
| 397 | return "failed" in reply; |
| 398 | // that the process is gone is not for a trace into this module to say |
| 399 | }).catch((error: Error) => print(formatted(ansi.style(ansi.fgRed, error.message)))), |
| 400 | async complete(...complete) { |
| 401 | let found: Completion | null = null; |
| 402 | await ask({ complete }, (reply) => { |
| 403 | if ("found" in reply) found = reply.found; |
| 404 | return "found" in reply; |
| 405 | }).catch(() => {}); |
| 406 | return found; |
| 407 | }, |
| 408 | }; |
| 409 | } |
| 410 | |
| 411 | type Request = |
| 412 | | { id: number; eval: string } |
| 413 | | { id: number; complete: Parameters<NonNullable<Language["complete"]>> }; |
| 414 | type Reply = |
| 415 | | { id: number; print: string } |
| 416 | // how evaluating ended: with nothing, or with a report of what was thrown |
| 417 | | { id: number; failed: string | null } |
| 418 | | { id: number; found: Completion | null }; |
| 419 | |
| 420 | /** the key that messages of this module are under */ |
| 421 | const wire = "@clo/lib/repl"; |
| 422 | |
| 423 | /** send a message, to hear through `sent` whether it could be. node.js would otherwise throw from nowhere. */ |
| 424 | function post(channel: Channel, payload: object, sent: (error: Error | null) => void) { |
| 425 | ASSERT(channel.send, "this process was not started with a channel to its parent"); |
| 426 | channel.send({ [wire]: payload }, sent); |
| 427 | } |
| 428 | |
| 429 | function unpack<T>(message: unknown): T | undefined { |
| 430 | return typeof message === "object" && message !== null && wire in message |
| 431 | ? (message as Record<string, T>)[wire] |
| 432 | : undefined; |
| 433 | } |
| 434 | |
| 435 | /** a value that formats as `text`, for what was already formatted elsewhere */ |
| 436 | function formatted(text: string): unknown { |
| 437 | return { [Symbol.for("nodejs.util.inspect.custom")]: () => text, toString: () => text }; |
| 438 | } |
| 439 | |
| 440 | /** lex all of `text` */ |
| 441 | export function tokenize(tokenizer: Tokenizer, text: string): Token[] { |
| 442 | const tokens: Token[] = []; |
| 443 | retokenize(tokenizer, tokens, text, 0, 0, text.length); |
| 444 | return tokens; |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * update `tokens` in place after an edit, where `text` is the result of |
| 449 | * replacing `removed` characters at `start` with `inserted` characters. |
| 450 | * lexing starts just before the edit, and stops at the first token that ends |
| 451 | * where a token used to end with the same state, since everything after it |
| 452 | * would lex the same as before. |
| 453 | */ |
| 454 | export function retokenize( |
| 455 | tokenizer: Tokenizer, |
| 456 | tokens: Token[], |
| 457 | text: string, |
| 458 | start: number, |
| 459 | removed: number, |
| 460 | inserted: number, |
| 461 | ): void { |
| 462 | const shift = inserted - removed; |
| 463 | // one token before the first that touches the edit, as it may have looked |
| 464 | // ahead into it |
| 465 | const first = Math.max(0, tokenIndex(tokens, start - 1) - 1); |
| 466 | let offset = tokens[first - 1]?.end ?? 0; |
| 467 | let state = tokens[first - 1]?.state ?? tokenizer.start; |
| 468 | |
| 469 | const fresh: Token[] = []; |
| 470 | let old = first; |
| 471 | let kept = tokens.length; |
| 472 | while (offset < text.length) { |
| 473 | const token = tokenizer.next(text, offset, state); |
| 474 | ASSERT(token.end > offset && token.end <= text.length, "Tokenizer did not advance"); |
| 475 | fresh.push(token); |
| 476 | ({ end: offset, state } = token); |
| 477 | |
| 478 | if (offset < start + inserted) continue; |
| 479 | while (old < tokens.length && UNWRAP(tokens[old]).end + shift < offset) old += 1; |
| 480 | const before = tokens[old]; |
| 481 | if (before && before.end + shift === offset && before.state === state) { |
| 482 | kept = old + 1; |
| 483 | break; |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | const rest = tokens.splice(kept); |
| 488 | tokens.length = first; |
| 489 | for (const token of fresh) tokens.push(token); |
| 490 | for (const token of rest) { |
| 491 | token.end += shift; |
| 492 | tokens.push(token); |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | /** style `code` with ANSI colors */ |
| 497 | export function highlight( |
| 498 | language: Pick<Language, "tokenizer" | "styles">, |
| 499 | code: string, |
| 500 | ): string { |
| 501 | return paint(code, tokenize(language.tokenizer, code), language.styles).join("\n"); |
| 502 | } |
| 503 | |
| 504 | /** see {@linkcode Editor.menu} */ |
| 505 | export interface Menu { |
| 506 | /** an option stands in for the text from here to the cursor */ |
| 507 | from: number; |
| 508 | options: string[]; |
| 509 | /** index of the highlighted option */ |
| 510 | selected: number; |
| 511 | } |
| 512 | |
| 513 | /** |
| 514 | * a line editor without a terminal: methods are the editing actions, and the |
| 515 | * public fields are everything needed to draw it. the methods that take no |
| 516 | * arguments are the ones that keys can be {@link Binding|bound} to. |
| 517 | * |
| 518 | * the methods are meant to be called through {@linkcode run}, one call per |
| 519 | * key press, since some act differently in a row: kills collect into one |
| 520 | * yank, vertical movement remembers its column, typing undoes as a unit. it |
| 521 | * is also `run` that brings up completions for what was typed. |
| 522 | */ |
| 523 | export class Editor { |
| 524 | text = ""; |
| 525 | /** an offset into `text`, never inside a grapheme */ |
| 526 | cursor = 0; |
| 527 | /** `text` as lexed by the tokenizer, kept current through every edit */ |
| 528 | tokens: Token[] = []; |
| 529 | /** entered lines, oldest first */ |
| 530 | history: string[]; |
| 531 | /** completions on offer, narrowed down to what the typed text matches */ |
| 532 | menu: Menu | null = null; |
| 533 | /** the query of a history search, while searching */ |
| 534 | search: { query: string; failed: boolean } | null = null; |
| 535 | |
| 536 | #syntax: Pick<Language, "tokenizer" | "complete">; |
| 537 | #undo: Edit[] = []; |
| 538 | #redo: Edit[] = []; |
| 539 | #kills: string[] = []; |
| 540 | #yanked = { from: 0, index: 0 }; |
| 541 | /** index of the history entry on display, or null for the line being written */ |
| 542 | #recalled: number | null = null; |
| 543 | #draft = ""; |
| 544 | #searchAt = 0; |
| 545 | #searchOrigin = ""; |
| 546 | #goal = 0; |
| 547 | /** what kind of action this is, and what the one before it was */ |
| 548 | #mark = ""; |
| 549 | #previous = ""; |
| 550 | /** completions that the latest key press is still waiting for */ |
| 551 | #late: Promise<void> | undefined; |
| 552 | |
| 553 | constructor( |
| 554 | syntax: Pick<Language, "tokenizer" | "complete">, |
| 555 | history: string[] = [], |
| 556 | ) { |
| 557 | this.#syntax = syntax; |
| 558 | this.history = history; |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * perform the actions of one key press. completions that the language |
| 563 | * has yet to answer with are on the {@linkcode menu} once what this |
| 564 | * returns has resolved. |
| 565 | */ |
| 566 | run(action: () => void): Promise<void> | undefined { |
| 567 | this.#previous = this.#mark; |
| 568 | this.#mark = ""; |
| 569 | this.#late = undefined; |
| 570 | const asked = this.menu !== null; |
| 571 | action(); |
| 572 | // any other action leaves the search with what it found |
| 573 | if (this.#mark !== "search") this.search = null; |
| 574 | // typing narrows down the completions on offer, or brings some up |
| 575 | if (this.#mark === "type" || this.#mark === "erase") this.#suggest(asked); |
| 576 | else if (this.#mark !== "menu") this.menu = null; |
| 577 | return this.#late; |
| 578 | } |
| 579 | |
| 580 | /** |
| 581 | * replace the text from `from` to `to`, leaving the cursor after what was |
| 582 | * inserted, unless told where. every edit passes through here. |
| 583 | */ |
| 584 | replace(from: number, to: number, insert: string, cursor: number = from + insert.length): void { |
| 585 | const removed = this.text.slice(from, to); |
| 586 | if (removed !== insert) { |
| 587 | this.#undo.push({ from, removed, inserted: insert, before: this.cursor, after: cursor }); |
| 588 | this.#redo = []; |
| 589 | this.#apply(from, to, insert); |
| 590 | } |
| 591 | this.cursor = cursor; |
| 592 | } |
| 593 | |
| 594 | /** insert at the cursor as if typed, which is how unbound keys act */ |
| 595 | type(text: string): void { |
| 596 | if (text === "") return; |
| 597 | if (this.search) { |
| 598 | this.search.query += text; |
| 599 | this.#find(this.#searchAt, -1); |
| 600 | return; |
| 601 | } |
| 602 | |
| 603 | const last = this.#undo.at(-1); |
| 604 | const continues = this.#previous === "type" && last?.removed === "" |
| 605 | && last.from + last.inserted.length === this.cursor; |
| 606 | this.replace(this.cursor, this.cursor, text); |
| 607 | if (continues) { |
| 608 | this.#undo.pop(); |
| 609 | last.inserted += text; |
| 610 | last.after = this.cursor; |
| 611 | } |
| 612 | this.#mark = "type"; |
| 613 | |
| 614 | // a closing bracket on a blank line belongs one level out |
| 615 | const start = lineStart(this.text, this.cursor); |
| 616 | const before = this.text.slice(start, this.cursor - 1); |
| 617 | if (/^[)\]}]$/.test(text) && before !== "" && before.trim() === "") { |
| 618 | const end = this.cursor - Math.min(indent.length, before.length); |
| 619 | this.replace(end - 1, this.cursor - 1, "", end); |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | /** end the line, returning it. it joins the history unless blank or a repeat. */ |
| 624 | accept(): string { |
| 625 | const { text } = this; |
| 626 | if (text.trim() && text !== this.history.at(-1)) this.history.push(text); |
| 627 | this.clear(); |
| 628 | return text; |
| 629 | } |
| 630 | |
| 631 | /** throw away the line */ |
| 632 | clear(): void { |
| 633 | this.#apply(0, this.text.length, ""); |
| 634 | this.cursor = 0; |
| 635 | this.#undo = []; |
| 636 | this.#redo = []; |
| 637 | this.search = null; |
| 638 | this.menu = null; |
| 639 | } |
| 640 | |
| 641 | moveLeft(): void { |
| 642 | this.cursor = this.#grapheme(-1); |
| 643 | } |
| 644 | moveRight(): void { |
| 645 | this.cursor = this.#grapheme(1); |
| 646 | } |
| 647 | moveWordLeft(): void { |
| 648 | this.cursor = this.#word(-1, isWord); |
| 649 | } |
| 650 | moveWordRight(): void { |
| 651 | this.cursor = this.#word(1, isWord); |
| 652 | } |
| 653 | moveLineStart(): void { |
| 654 | this.cursor = lineStart(this.text, this.cursor); |
| 655 | } |
| 656 | moveLineEnd(): void { |
| 657 | this.cursor = lineEnd(this.text, this.cursor); |
| 658 | } |
| 659 | moveStart(): void { |
| 660 | this.cursor = 0; |
| 661 | } |
| 662 | moveEnd(): void { |
| 663 | this.cursor = this.text.length; |
| 664 | } |
| 665 | /** |
| 666 | * to the option above in the {@linkcode menu}, else to the line above, or |
| 667 | * from the first line, to the previous history entry |
| 668 | */ |
| 669 | moveUp(): void { |
| 670 | if (this.menu) return this.#select(-1); |
| 671 | const start = lineStart(this.text, this.cursor); |
| 672 | if (start === 0) return this.historyPrevious(); |
| 673 | this.#vertical(start, lineStart(this.text, start - 1)); |
| 674 | } |
| 675 | /** the opposite of {@linkcode moveUp} */ |
| 676 | moveDown(): void { |
| 677 | if (this.menu) return this.#select(1); |
| 678 | const end = lineEnd(this.text, this.cursor); |
| 679 | if (end === this.text.length) return this.historyNext(); |
| 680 | this.#vertical(lineStart(this.text, this.cursor), end + 1); |
| 681 | } |
| 682 | |
| 683 | deleteLeft(): void { |
| 684 | if (this.search) { |
| 685 | this.search.query = this.search.query.slice(0, this.#graphemeIn(this.search.query, -1)); |
| 686 | this.#find(this.history.length, -1); |
| 687 | return; |
| 688 | } |
| 689 | this.replace(this.#grapheme(-1), this.cursor, ""); |
| 690 | this.#mark = "erase"; |
| 691 | } |
| 692 | deleteRight(): void { |
| 693 | this.replace(this.cursor, this.#grapheme(1), ""); |
| 694 | } |
| 695 | killWordLeft(): void { |
| 696 | this.#kill(this.#word(-1, isWord), this.cursor); |
| 697 | } |
| 698 | killWordRight(): void { |
| 699 | this.#kill(this.cursor, this.#word(1, isWord)); |
| 700 | } |
| 701 | /** kill back to whitespace, which takes a whole path where a word is one segment */ |
| 702 | killToSpaceLeft(): void { |
| 703 | this.#kill(this.#word(-1, (char) => /\S/.test(char)), this.cursor); |
| 704 | } |
| 705 | killLineStart(): void { |
| 706 | this.#kill(lineStart(this.text, this.cursor), this.cursor); |
| 707 | } |
| 708 | killLineEnd(): void { |
| 709 | this.#kill(this.cursor, lineEnd(this.text, this.cursor)); |
| 710 | } |
| 711 | /** insert what was last killed */ |
| 712 | yank(): void { |
| 713 | const index = this.#kills.length - 1; |
| 714 | if (index < 0) return; |
| 715 | this.#yanked = { from: this.cursor, index }; |
| 716 | this.replace(this.cursor, this.cursor, UNWRAP(this.#kills[index])); |
| 717 | this.#mark = "yank"; |
| 718 | } |
| 719 | /** following a yank, swap it for the kill before */ |
| 720 | yankPop(): void { |
| 721 | if (this.#previous !== "yank") return; |
| 722 | const { from } = this.#yanked; |
| 723 | const index = (this.#yanked.index || this.#kills.length) - 1; |
| 724 | this.#yanked = { from, index }; |
| 725 | this.replace(from, this.cursor, UNWRAP(this.#kills[index])); |
| 726 | this.#mark = "yank"; |
| 727 | } |
| 728 | |
| 729 | undo(): void { |
| 730 | const edit = this.#undo.pop(); |
| 731 | if (!edit) return; |
| 732 | this.#apply(edit.from, edit.from + edit.inserted.length, edit.removed); |
| 733 | this.cursor = edit.before; |
| 734 | this.#redo.push(edit); |
| 735 | } |
| 736 | redo(): void { |
| 737 | const edit = this.#redo.pop(); |
| 738 | if (!edit) return; |
| 739 | this.#apply(edit.from, edit.from + edit.removed.length, edit.inserted); |
| 740 | this.cursor = edit.after; |
| 741 | this.#undo.push(edit); |
| 742 | } |
| 743 | |
| 744 | /** break the line, indented like the one it leaves, and deeper after an open bracket */ |
| 745 | newline(): void { |
| 746 | const before = this.text.slice(lineStart(this.text, this.cursor), this.cursor); |
| 747 | const deeper = /[([{]\s*$/.test(before) ? indent : ""; |
| 748 | this.replace(this.cursor, this.cursor, "\n" + UNWRAP(/^[ \t]*/.exec(before))[0] + deeper); |
| 749 | } |
| 750 | |
| 751 | /** |
| 752 | * take the highlighted completion. without a {@linkcode menu}, ask for |
| 753 | * one, taking the completion right away if there is only the one. on a |
| 754 | * blank line, this indents instead. |
| 755 | */ |
| 756 | complete(): void { |
| 757 | const { menu, text, cursor } = this; |
| 758 | if (menu) return this.replace(menu.from, cursor, UNWRAP(menu.options[menu.selected])); |
| 759 | if (text.slice(lineStart(text, cursor), cursor).trim() === "") { |
| 760 | return this.replace(cursor, cursor, indent); |
| 761 | } |
| 762 | |
| 763 | this.#suggest(true, true); |
| 764 | this.#mark = "menu"; |
| 765 | } |
| 766 | |
| 767 | /** close the menu, or abandon a history search for the text from before it */ |
| 768 | cancel(): void { |
| 769 | if (this.search) this.replace(0, this.text.length, this.#searchOrigin); |
| 770 | } |
| 771 | |
| 772 | historyPrevious(): void { |
| 773 | this.#recall(-1, () => true); |
| 774 | } |
| 775 | historyNext(): void { |
| 776 | this.#recall(1, () => true); |
| 777 | } |
| 778 | /** the previous history entry that starts with the text before the cursor */ |
| 779 | historyPrefixPrevious(): void { |
| 780 | const prefix = this.text.slice(0, this.cursor); |
| 781 | this.#recall(-1, (entry) => entry.startsWith(prefix), this.cursor); |
| 782 | } |
| 783 | historyPrefixNext(): void { |
| 784 | const prefix = this.text.slice(0, this.cursor); |
| 785 | this.#recall(1, (entry) => entry.startsWith(prefix), this.cursor); |
| 786 | } |
| 787 | /** |
| 788 | * start searching the history for what is typed next. while searching, |
| 789 | * step to the next older match. |
| 790 | */ |
| 791 | searchBackward(): void { |
| 792 | this.#search(-1); |
| 793 | } |
| 794 | /** like {@linkcode searchBackward}, towards newer entries */ |
| 795 | searchForward(): void { |
| 796 | this.#search(1); |
| 797 | } |
| 798 | |
| 799 | #apply(from: number, to: number, insert: string) { |
| 800 | this.text = this.text.slice(0, from) + insert + this.text.slice(to); |
| 801 | retokenize(this.#syntax.tokenizer, this.tokens, this.text, from, to - from, insert.length); |
| 802 | // changed text is a new line being written, wherever it came from |
| 803 | this.#recalled = null; |
| 804 | } |
| 805 | |
| 806 | /** the edge of the grapheme before or after the cursor, crossing lines */ |
| 807 | #grapheme(step: -1 | 1): number { |
| 808 | const { text, cursor } = this; |
| 809 | const start = lineStart(text, cursor); |
| 810 | const end = lineEnd(text, cursor); |
| 811 | if (cursor === (step < 0 ? start : end)) { |
| 812 | return Math.max(0, Math.min(text.length, cursor + step)); |
| 813 | } |
| 814 | return step < 0 |
| 815 | ? start + this.#graphemeIn(text.slice(start, cursor), -1) |
| 816 | : cursor + this.#graphemeIn(text.slice(cursor, end), 1); |
| 817 | } |
| 818 | |
| 819 | /** the offset in `text` of its last grapheme (-1), or of the end of its first (1) */ |
| 820 | #graphemeIn(text: string, step: -1 | 1): number { |
| 821 | if (text === "") return 0; |
| 822 | segmenter ??= new Intl.Segmenter(); |
| 823 | const segments = segmenter.segment(text); |
| 824 | return step < 0 |
| 825 | ? UNWRAP(segments.containing(text.length - 1)).index |
| 826 | : UNWRAP(segments.containing(0)).segment.length; |
| 827 | } |
| 828 | |
| 829 | /** past the run of `inWord` characters in a direction, and whatever came before it */ |
| 830 | #word(step: -1 | 1, inWord: (char: string) => boolean): number { |
| 831 | const { text } = this; |
| 832 | let i = this.cursor; |
| 833 | const next = () => text[step < 0 ? i - 1 : i]; |
| 834 | for (let char = next(); char !== undefined && !inWord(char); char = next()) i += step; |
| 835 | for (let char = next(); char !== undefined && inWord(char); char = next()) i += step; |
| 836 | // a grapheme can be made of both kinds of character |
| 837 | return snap(text, i, step); |
| 838 | } |
| 839 | |
| 840 | /** move from the line at `from` to the line at `to`, staying in the column */ |
| 841 | #vertical(from: number, to: number) { |
| 842 | if (this.#previous !== "vertical") { |
| 843 | this.#goal = cells(this.text, from, this.cursor) |
| 844 | .reduce((width, cell) => width + cell.width, 0); |
| 845 | } |
| 846 | this.#mark = "vertical"; |
| 847 | |
| 848 | let width = 0; |
| 849 | this.cursor = lineEnd(this.text, to); |
| 850 | for (const cell of cells(this.text, to, this.cursor)) { |
| 851 | if (width >= this.#goal) { |
| 852 | this.cursor = cell.offset; |
| 853 | break; |
| 854 | } |
| 855 | width += cell.width; |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | #kill(from: number, to: number) { |
| 860 | const killed = this.text.slice(from, to); |
| 861 | if (killed === "") return; |
| 862 | // kills in a row are one kill, in the order of the text |
| 863 | if (this.#previous !== "kill") this.#kills.push(killed); |
| 864 | else if (to === this.cursor) this.#kills.push(killed + this.#kills.pop()); |
| 865 | else this.#kills.push(this.#kills.pop() + killed); |
| 866 | this.replace(from, to, ""); |
| 867 | this.#mark = "kill"; |
| 868 | } |
| 869 | |
| 870 | /** bring the {@linkcode menu} up to date with the text */ |
| 871 | /** ask the language what to offer, to `take` it right away if it is all there is */ |
| 872 | #suggest(asked: boolean, take = false) { |
| 873 | const { text, cursor } = this; |
| 874 | const kind = this.tokens[tokenIndex(this.tokens, cursor - 1)]?.kind ?? ""; |
| 875 | const found = this.#syntax.complete?.(text, cursor, asked, kind) ?? null; |
| 876 | if (!(found instanceof Promise)) return this.#offer(found, take); |
| 877 | |
| 878 | // until the answer is there, what was on offer is narrowed down as it is |
| 879 | this.#offer(this.menu && this.menu.from <= cursor ? this.menu : null, false); |
| 880 | const late = found.catch(() => null).then((answer) => { |
| 881 | // a key that was pressed since has asked anew, or wants no menu |
| 882 | if (this.#late === late) this.#offer(answer, take); |
| 883 | }); |
| 884 | this.#late = late; |
| 885 | } |
| 886 | |
| 887 | #offer(found: Completion | null, take: boolean) { |
| 888 | const { text, cursor } = this; |
| 889 | const typed = found ? text.slice(found.from, cursor) : ""; |
| 890 | const options = found ? narrow(found.options, typed) : []; |
| 891 | this.menu = null; |
| 892 | if (found && take && options.length === 1) { |
| 893 | return this.replace(found.from, cursor, UNWRAP(options[0])); |
| 894 | } |
| 895 | // an only option that is already typed out is nothing to offer |
| 896 | const offers = options.length > 1 || (options.length === 1 && options[0] !== typed); |
| 897 | if (found && offers) this.menu = { from: found.from, options, selected: 0 }; |
| 898 | } |
| 899 | |
| 900 | #select(step: -1 | 1) { |
| 901 | const menu = UNWRAP(this.menu); |
| 902 | const count = menu.options.length; |
| 903 | menu.selected = (menu.selected + step + count) % count; |
| 904 | this.#mark = "menu"; |
| 905 | } |
| 906 | |
| 907 | /** |
| 908 | * show the nearest history entry in a direction that `matches`. the line |
| 909 | * being written sits after the newest entry, to come back to. |
| 910 | */ |
| 911 | #recall(step: -1 | 1, matches: (entry: string) => boolean, cursor?: number) { |
| 912 | const { history } = this; |
| 913 | let at = this.#recalled ?? history.length; |
| 914 | do at += step; while (at >= 0 && at < history.length && !matches(UNWRAP(history[at]))); |
| 915 | if (at < 0 || (at >= history.length && this.#recalled === null)) return; |
| 916 | |
| 917 | if (this.#recalled === null) this.#draft = this.text; |
| 918 | const entry = history[at] ?? this.#draft; |
| 919 | this.replace(0, this.text.length, entry, snap(entry, cursor ?? entry.length, -1)); |
| 920 | this.#recalled = at < history.length ? at : null; |
| 921 | } |
| 922 | |
| 923 | #search(step: -1 | 1) { |
| 924 | if (this.search) return this.#find(this.#searchAt + step, step); |
| 925 | this.search = { query: "", failed: false }; |
| 926 | this.#searchOrigin = this.text; |
| 927 | this.#searchAt = this.history.length; |
| 928 | this.#mark = "search"; |
| 929 | } |
| 930 | |
| 931 | /** |
| 932 | * show the first history entry containing the query, looking from `at`. |
| 933 | * without a query, that is the text from before the search. |
| 934 | */ |
| 935 | #find(at: number, step: -1 | 1) { |
| 936 | const { history } = this; |
| 937 | const search = UNWRAP(this.search); |
| 938 | const { query } = search; |
| 939 | this.#mark = "search"; |
| 940 | |
| 941 | at = query === "" ? history.length : Math.min(at, history.length - 1); |
| 942 | while (at >= 0 && at < history.length && !UNWRAP(history[at]).includes(query)) at += step; |
| 943 | search.failed = at < 0 || (at >= history.length && query !== ""); |
| 944 | if (search.failed) return; |
| 945 | |
| 946 | this.#searchAt = at; |
| 947 | const entry = history[at] ?? this.#searchOrigin; |
| 948 | const found = query ? entry.indexOf(query) + query.length : entry.length; |
| 949 | this.replace(0, this.text.length, entry, found); |
| 950 | } |
| 951 | } |
| 952 | |
| 953 | interface Edit { |
| 954 | from: number; |
| 955 | removed: string; |
| 956 | inserted: string; |
| 957 | /** where the cursor was, and where the edit left it */ |
| 958 | before: number; |
| 959 | after: number; |
| 960 | } |
| 961 | |
| 962 | /** |
| 963 | * what a key does: the name of an {@linkcode Editor} method that takes no |
| 964 | * arguments, or a function. functions run within {@linkcode Editor.run}. |
| 965 | */ |
| 966 | export type Binding = EditorAction | ((editor: Editor, repl: Repl) => void); |
| 967 | type EditorAction = { |
| 968 | [K in keyof Editor]: Editor[K] extends () => void ? K : never; |
| 969 | }[keyof Editor]; |
| 970 | |
| 971 | /** |
| 972 | * the default key bindings, by {@link log.WidgetInput.key|key name}. these |
| 973 | * follow readline, with the arrow and modifier keys of graphical text fields |
| 974 | * added. keys without a binding type their text. |
| 975 | * |
| 976 | * while completions are on offer, the up and down keys move among them, and |
| 977 | * tab or enter takes the highlighted one, which escape declines. |
| 978 | */ |
| 979 | export const keymap: Record<string, Binding> = { |
| 980 | "enter": (_, repl) => repl.submit(), |
| 981 | "shift+enter": "newline", |
| 982 | "alt+enter": "newline", |
| 983 | "ctrl+j": "newline", |
| 984 | "tab": "complete", |
| 985 | "escape": "cancel", |
| 986 | "ctrl+g": "cancel", |
| 987 | "ctrl+c": interrupt, |
| 988 | "ctrl+d": (editor, repl) => editor.text ? editor.deleteRight() : repl.close(), |
| 989 | "ctrl+z": suspend, |
| 990 | "ctrl+l": (_, repl) => repl.clearScreen(), |
| 991 | |
| 992 | "left": "moveLeft", |
| 993 | "ctrl+b": "moveLeft", |
| 994 | "right": "moveRight", |
| 995 | "ctrl+f": "moveRight", |
| 996 | "ctrl+left": "moveWordLeft", |
| 997 | "alt+left": "moveWordLeft", |
| 998 | "alt+b": "moveWordLeft", |
| 999 | "ctrl+right": "moveWordRight", |
| 1000 | "alt+right": "moveWordRight", |
| 1001 | "alt+f": "moveWordRight", |
| 1002 | "home": "moveLineStart", |
| 1003 | "ctrl+a": "moveLineStart", |
| 1004 | "end": "moveLineEnd", |
| 1005 | "ctrl+e": "moveLineEnd", |
| 1006 | "ctrl+home": "moveStart", |
| 1007 | "ctrl+end": "moveEnd", |
| 1008 | "up": "moveUp", |
| 1009 | "ctrl+p": "moveUp", |
| 1010 | "down": "moveDown", |
| 1011 | "ctrl+n": "moveDown", |
| 1012 | |
| 1013 | "backspace": "deleteLeft", |
| 1014 | "shift+backspace": "deleteLeft", |
| 1015 | "ctrl+h": "deleteLeft", |
| 1016 | "delete": "deleteRight", |
| 1017 | "alt+backspace": "killWordLeft", |
| 1018 | "ctrl+backspace": "killWordLeft", |
| 1019 | "alt+d": "killWordRight", |
| 1020 | "ctrl+delete": "killWordRight", |
| 1021 | "ctrl+w": "killToSpaceLeft", |
| 1022 | "ctrl+u": "killLineStart", |
| 1023 | "ctrl+k": "killLineEnd", |
| 1024 | "ctrl+y": "yank", |
| 1025 | "alt+y": "yankPop", |
| 1026 | "ctrl+_": "undo", |
| 1027 | "ctrl+/": "undo", |
| 1028 | "alt+/": "redo", |
| 1029 | "ctrl+shift+z": "redo", |
| 1030 | |
| 1031 | "pageup": "historyPrefixPrevious", |
| 1032 | "pagedown": "historyPrefixNext", |
| 1033 | "ctrl+r": "searchBackward", |
| 1034 | "ctrl+s": "searchForward", |
| 1035 | }; |
| 1036 | |
| 1037 | function interrupt(editor: Editor, repl: Repl) { |
| 1038 | if (editor.text || editor.search) editor.clear(); |
| 1039 | else repl.interrupt(); |
| 1040 | } |
| 1041 | |
| 1042 | function suspend(_: Editor, repl: Repl) { |
| 1043 | repl.suspend(); |
| 1044 | } |
| 1045 | |
| 1046 | /** what to draw an {@linkcode Editor} into */ |
| 1047 | export interface View { |
| 1048 | /** cells per row */ |
| 1049 | width: number; |
| 1050 | /** rows at most. taller text scrolls to keep the cursor among them. */ |
| 1051 | height: number; |
| 1052 | prompt: string; |
| 1053 | styles: Record<string, string>; |
| 1054 | style?: Style | undefined; |
| 1055 | title?: string | undefined; |
| 1056 | placeholder?: string | undefined; |
| 1057 | /** rows under the prompt that are left blank when nothing is drawn there */ |
| 1058 | reserve?: number | undefined; |
| 1059 | /** the `scroll` of the frame before, to scroll no further than needed */ |
| 1060 | scroll?: number; |
| 1061 | } |
| 1062 | |
| 1063 | export interface Frame extends log.WidgetFrame { |
| 1064 | cursor: { line: number; column: number }; |
| 1065 | /** index of the first row of text that is in view */ |
| 1066 | scroll: number; |
| 1067 | } |
| 1068 | |
| 1069 | /** |
| 1070 | * draw an {@linkcode Editor}: its text behind the prompt, soft wrapped and |
| 1071 | * styled by its tokens, with its completions in a box underneath, which is |
| 1072 | * where rows can come and go without the text moving. a |
| 1073 | * {@linkcode Style} other than `"plain"` adds a row above and below. |
| 1074 | */ |
| 1075 | export function formatAnsi(editor: Editor, view: View): Frame { |
| 1076 | const { text, cursor, search, menu } = editor; |
| 1077 | const { width, style = "plain" } = view; |
| 1078 | const gutter = ansi.widthInTerminal(view.prompt); |
| 1079 | const columns = Math.max(1, width - gutter); |
| 1080 | const rows = layout(text, columns); |
| 1081 | const cursorRow = rows.findLastIndex((row) => row.from <= cursor); |
| 1082 | const rowStart = UNWRAP(rows[cursorRow]).from; |
| 1083 | /** the cell that an offset on the cursor's row is drawn in */ |
| 1084 | const column = (offset: number) => cells(text, rowStart, offset).reduce((width, cell) => width + cell.width, gutter); |
| 1085 | |
| 1086 | const framed = style !== "plain"; |
| 1087 | /** as many cells of the row above the text, which is also the one below */ |
| 1088 | const edge = (cells: number) => style === "lines" ? ansi.style(ansi.dim, "─".repeat(cells)) : " ".repeat(cells); |
| 1089 | /** a row of the prompt, on the background if the style has one */ |
| 1090 | const backed = (line: string) => |
| 1091 | style !== "filled" |
| 1092 | ? line |
| 1093 | : filled + line.replaceAll(ansi.reset, ansi.reset + filled) |
| 1094 | + " ".repeat(Math.max(0, width - ansi.widthInTerminal(line))) + ansi.reset; |
| 1095 | |
| 1096 | let top = edge(width); |
| 1097 | const titleRoom = width - (style === "lines" ? 6 : gutter); |
| 1098 | if (view.title && titleRoom > 0) { |
| 1099 | const title = ansi.trimForTerminal(view.title, titleRoom); |
| 1100 | top = style === "lines" |
| 1101 | ? ansi.style(ansi.dim, "─┤") + " " + title + " " |
| 1102 | + ansi.style(ansi.dim, "├" + "─".repeat(titleRoom + 1 - ansi.widthInTerminal(title))) |
| 1103 | : " ".repeat(gutter) + ansi.style(ansi.bold, title); |
| 1104 | } |
| 1105 | const above = framed ? [backed(top)] : []; |
| 1106 | |
| 1107 | // the text gets its first row before the menu gets any. the box of the |
| 1108 | // menu starts on the row under the text, as if it hung from it. |
| 1109 | const taken = (framed ? 2 : 0) + (search ? 1 : 0); |
| 1110 | const spare = Math.max(0, view.height - taken - 1) + (framed ? 1 : 0); |
| 1111 | const box = menu && spare > 0 |
| 1112 | ? formatMenu(menu, text.slice(menu.from, cursor), Math.min(menuRows, spare)) |
| 1113 | : { lines: [], width: 0 }; |
| 1114 | // lined up with what is being completed, as far as the box fits there |
| 1115 | const left = menu ? Math.max(0, Math.min(column(Math.max(rowStart, menu.from)) - 1, width - box.width)) : 0; |
| 1116 | const below = box.lines.map((line) => " ".repeat(left) + line); |
| 1117 | if (framed) { |
| 1118 | const [first] = box.lines; |
| 1119 | below[0] = backed(first === undefined ? edge(width) : edge(left) + first + edge(width - left - box.width)); |
| 1120 | } |
| 1121 | if (search) { |
| 1122 | below.push( |
| 1123 | ansi.style(ansi.dim, "search:") + " " + search.query.replace(/\p{Cc}/gu, " ") |
| 1124 | + (search.failed ? " " + ansi.style(ansi.fgRed, "no match") : ""), |
| 1125 | ); |
| 1126 | } |
| 1127 | // the text keeps a row before any is left blank |
| 1128 | const kept = below.length - (framed ? 1 : 0); |
| 1129 | const blank = Math.min((view.reserve ?? 0) - kept, view.height - above.length - below.length - 1); |
| 1130 | for (let i = 0; i < blank; i += 1) below.push(""); |
| 1131 | |
| 1132 | const height = Math.max(1, view.height - above.length - below.length); |
| 1133 | const last = Math.max(0, rows.length - height); |
| 1134 | const scroll = Math.min( |
| 1135 | last, |
| 1136 | Math.max(cursorRow - height + 1, Math.min(view.scroll ?? 0, cursorRow)), |
| 1137 | ); |
| 1138 | const shown = rows.slice(scroll, scroll + height); |
| 1139 | |
| 1140 | const lines = shown.map((row, i) => { |
| 1141 | const prefix = i === 0 && scroll > 0 |
| 1142 | ? margin(gutter, "↑") |
| 1143 | : i === shown.length - 1 && scroll < last |
| 1144 | ? margin(gutter, "↓") |
| 1145 | : row.from === 0 |
| 1146 | ? view.prompt |
| 1147 | : margin(gutter, row.head ? "…" : ""); |
| 1148 | const body = text === "" && view.placeholder |
| 1149 | ? ansi.style(ansi.dim, ansi.trimForTerminal(view.placeholder, columns)) |
| 1150 | : paintLine(text, editor.tokens, view.styles, row.from, row.to); |
| 1151 | return backed(prefix + body); |
| 1152 | }); |
| 1153 | |
| 1154 | return { |
| 1155 | text: [...above, ...lines, ...below].join("\n"), |
| 1156 | cursor: { line: above.length + cursorRow - scroll, column: column(cursor) }, |
| 1157 | scroll, |
| 1158 | }; |
| 1159 | } |
| 1160 | |
| 1161 | /** |
| 1162 | * a prompt at the end of the terminal, which is live from construction until |
| 1163 | * {@linkcode close}. see the module documentation. |
| 1164 | * |
| 1165 | * when the terminal is not interactive, nothing is shown or read. |
| 1166 | */ |
| 1167 | export class Repl implements log.WidgetOptions { |
| 1168 | /** the line being written */ |
| 1169 | readonly editor: Editor; |
| 1170 | /** looked up as lines are entered, so commands can be added at any time */ |
| 1171 | readonly commands: Record<string, Command | CommandExecute>; |
| 1172 | /** work started on this is displayed under the prompt */ |
| 1173 | readonly progress: progress.Ref; |
| 1174 | |
| 1175 | #host: Host; |
| 1176 | #widget: log.WidgetInstance | null; |
| 1177 | #language: Language | null; |
| 1178 | #syntax: Syntax; |
| 1179 | #keymap: Record<string, Binding | null>; |
| 1180 | #look: Pick<View, "prompt" | "styles" | "style" | "title" | "placeholder">; |
| 1181 | #reserve: number; |
| 1182 | #historyFile: string | undefined; |
| 1183 | /** the widget that `progress.ts` draws, which is nested into this one */ |
| 1184 | #status: log.WidgetOptions | null = null; |
| 1185 | #detachStatus: ts.Dispose; |
| 1186 | #scroll = 0; |
| 1187 | /** how many things the prompt is hidden for */ |
| 1188 | #blocking = 0; |
| 1189 | /** keys pressed while the prompt was hidden */ |
| 1190 | #typeahead: log.WidgetInput[] = []; |
| 1191 | |
| 1192 | constructor(options: Options = {}) { |
| 1193 | const { language = javascript(), commands = {} } = options; |
| 1194 | const host: Host = options.interface ?? log; |
| 1195 | this.commands = commands; |
| 1196 | this.#host = host; |
| 1197 | this.#language = language; |
| 1198 | this.#syntax = withCommands(language, commands); |
| 1199 | this.#keymap = { ...keymap, ...options.keymap }; |
| 1200 | this.#look = { |
| 1201 | prompt: options.prompt ?? ansi.style(ansi.fgBlue, ">") + " ", |
| 1202 | styles: this.#syntax.styles, |
| 1203 | style: options.style, |
| 1204 | title: options.title, |
| 1205 | placeholder: options.placeholder, |
| 1206 | }; |
| 1207 | this.#reserve = options.reserve ?? 1; |
| 1208 | this.#historyFile = options.history; |
| 1209 | this.editor = new Editor( |
| 1210 | this.#syntax, |
| 1211 | options.history ? readHistory(options.history) : [], |
| 1212 | ); |
| 1213 | |
| 1214 | const root = new progress.Root(host); |
| 1215 | this.progress = root; |
| 1216 | this.#detachStatus = progress.attachToScreen(root, { |
| 1217 | writeOutput: (text) => host.writeOutput(text), |
| 1218 | startWidget: (status) => { |
| 1219 | const widget = this.#widget; |
| 1220 | if (!widget) return null; |
| 1221 | this.#status = status; |
| 1222 | return { |
| 1223 | options: status, |
| 1224 | get fps() { |
| 1225 | return widget.fps; |
| 1226 | }, |
| 1227 | set fps(fps) { |
| 1228 | widget.fps = fps; |
| 1229 | }, |
| 1230 | redraw: widget.redraw, |
| 1231 | stop: () => { |
| 1232 | this.#status = null; |
| 1233 | widget.fps = null; |
| 1234 | widget.redraw(); |
| 1235 | }, |
| 1236 | [Symbol.dispose]() { |
| 1237 | this.stop(); |
| 1238 | }, |
| 1239 | }; |
| 1240 | }, |
| 1241 | }); |
| 1242 | this.#widget = host.startWidget(this); |
| 1243 | } |
| 1244 | |
| 1245 | /** @internal called by the widget host */ |
| 1246 | format(ctx: log.WidgetFormatContext): log.WidgetFrame { |
| 1247 | const drawn = this.#status?.format(ctx) ?? ""; |
| 1248 | const status = typeof drawn === "string" ? drawn : drawn.text; |
| 1249 | // without the keyboard, a prompt would only be in the way |
| 1250 | if (!ctx.focused || this.#blocking > 0) return { text: status }; |
| 1251 | |
| 1252 | const frame = formatAnsi(this.editor, { |
| 1253 | ...this.#look, |
| 1254 | // work that is displayed is in the rows that are kept for it |
| 1255 | reserve: this.#reserve - (status ? string.countNewlines(status) + 1 : 0), |
| 1256 | // the host keeps the last column and the last row of the terminal clear |
| 1257 | width: ctx.width - 1, |
| 1258 | height: ctx.height - 1, |
| 1259 | scroll: this.#scroll, |
| 1260 | }); |
| 1261 | this.#scroll = frame.scroll; |
| 1262 | // when there are too few rows for both, it is the prompt that is needed |
| 1263 | const room = ctx.height - 2 - string.countNewlines(frame.text); |
| 1264 | const shown = status ? status.split("\n").slice(0, Math.max(0, room)) : []; |
| 1265 | return { text: [frame.text, ...shown].join("\n"), cursor: frame.cursor }; |
| 1266 | } |
| 1267 | |
| 1268 | /** @internal called by the widget host */ |
| 1269 | input(event: log.WidgetInput): void { |
| 1270 | const binding = this.#keymap[event.key]; |
| 1271 | if (this.#blocking > 0 && binding !== interrupt && binding !== suspend) { |
| 1272 | this.#typeahead.push(event); |
| 1273 | return; |
| 1274 | } |
| 1275 | try { |
| 1276 | const late = this.editor.run(() => { |
| 1277 | if (typeof binding === "string") this.editor[binding](); |
| 1278 | else if (binding) binding(this.editor, this); |
| 1279 | else this.editor.type(event.text); |
| 1280 | }); |
| 1281 | late?.then(() => this.#widget?.redraw()); |
| 1282 | } catch (error) { |
| 1283 | this.#host.writeError(report(error) + "\n"); |
| 1284 | } |
| 1285 | this.#widget?.redraw(); |
| 1286 | } |
| 1287 | |
| 1288 | /** |
| 1289 | * enter the line. it moves into the log above the prompt, and is run as a |
| 1290 | * command or evaluated. a line that is incomplete gets a new line instead, |
| 1291 | * and with a completion highlighted, that is taken first. |
| 1292 | */ |
| 1293 | submit(): void { |
| 1294 | const { editor } = this; |
| 1295 | const { text, menu } = editor; |
| 1296 | if (menu && menu.options[menu.selected] !== text.slice(menu.from, editor.cursor)) { |
| 1297 | return editor.complete(); |
| 1298 | } |
| 1299 | if (text.trim() === "") return; |
| 1300 | const state = editor.tokens.at(-1)?.state ?? this.#syntax.tokenizer.start; |
| 1301 | if (this.#syntax.incomplete?.(text, state)) return editor.newline(); |
| 1302 | |
| 1303 | const { prompt } = this.#look; |
| 1304 | const continued = margin(ansi.widthInTerminal(prompt), "…"); |
| 1305 | const echo = paint(text, editor.tokens, this.#syntax.styles) |
| 1306 | .map((line, i) => (i === 0 ? prompt : continued) + line + "\n"); |
| 1307 | this.#host.writeError(echo.join("")); |
| 1308 | |
| 1309 | const remembered = editor.history.length; |
| 1310 | editor.accept(); |
| 1311 | if (this.#historyFile && editor.history.length > remembered) { |
| 1312 | UNWRAP(node.builtin("fs")) |
| 1313 | .appendFileSync(this.#historyFile, JSON.stringify(text) + "\n", { mode: 0o600 }); |
| 1314 | } |
| 1315 | |
| 1316 | const [, name, args = ""] = commandLine.exec(text) ?? []; |
| 1317 | const entry = name !== undefined && Object.hasOwn(this.commands, name) |
| 1318 | ? this.commands[name] |
| 1319 | : undefined; |
| 1320 | const command = typeof entry === "function" ? { execute: entry } : entry; |
| 1321 | const language = this.#language; |
| 1322 | if (!command && !language) { |
| 1323 | this.#host.writeError(ansi.style(ansi.fgRed, "not a command") + "\n"); |
| 1324 | return; |
| 1325 | } |
| 1326 | |
| 1327 | const print = (value: unknown) => this.#host.writeError(inspect(value) + "\n"); |
| 1328 | const run = async () => { |
| 1329 | if (!command) return UNWRAP(language).eval(text, print); |
| 1330 | using node = this.progress.start(text.trim()); |
| 1331 | await command.execute.call(this, args.trim(), node); |
| 1332 | }; |
| 1333 | // entered code starts off without the keyboard, so that a loop which |
| 1334 | // never yields is still ended by ctrl+c, as an interrupt from the terminal |
| 1335 | const lock = this.#host.getDrawLock("long"); |
| 1336 | const work = run(); |
| 1337 | lock.release("cursor-start-of-line"); |
| 1338 | |
| 1339 | const block = command ? command.block ?? false : true; |
| 1340 | if (block) this.#blocking += 1; |
| 1341 | work |
| 1342 | .catch((error) => this.#host.writeError(report(error) + "\n")) |
| 1343 | .finally(() => { |
| 1344 | if (block) this.#blocking -= 1; |
| 1345 | while (this.#blocking === 0 && this.#typeahead.length > 0) { |
| 1346 | this.input(UNWRAP(this.#typeahead.shift())); |
| 1347 | } |
| 1348 | this.#widget?.redraw(); |
| 1349 | }); |
| 1350 | } |
| 1351 | |
| 1352 | /** |
| 1353 | * what ctrl+c means to a program that is not reading the keyboard: every |
| 1354 | * process attached to the terminal is sent an interrupt. |
| 1355 | */ |
| 1356 | interrupt(): void { |
| 1357 | const process = node.process; |
| 1358 | if (!process) return; |
| 1359 | // the terminal is released first, as this might be the end |
| 1360 | const lock = this.#host.getDrawLock("long"); |
| 1361 | process.kill(process.platform === "win32" ? process.pid : 0, "SIGINT"); |
| 1362 | lock.release("cursor-start-of-line"); |
| 1363 | } |
| 1364 | |
| 1365 | /** what ctrl+z means: stop, handing the terminal to the shell until resumed */ |
| 1366 | suspend(): void { |
| 1367 | const spawnSync = node.builtin("child_process")?.spawnSync; |
| 1368 | if (!spawnSync || node.process?.platform === "win32") return; |
| 1369 | const lock = this.#host.getDrawLock("long"); |
| 1370 | // node.js assumes that a process signaling itself is about to exit, and |
| 1371 | // shuts down what `javascript` evaluates through. a shell signals |
| 1372 | // the process group instead, and returns once that is resumed. |
| 1373 | spawnSync("/bin/sh", ["-c", "kill -TSTP 0"], { stdio: "ignore" }); |
| 1374 | lock.release("cursor-start-of-line"); |
| 1375 | } |
| 1376 | |
| 1377 | /** scroll the terminal clear, leaving the prompt at the top */ |
| 1378 | clearScreen(): void { |
| 1379 | this.#host.writeError(ansi.cursorHome + ansi.clearToEndOfScreen); |
| 1380 | } |
| 1381 | |
| 1382 | /** remove the prompt and stop reading the keyboard */ |
| 1383 | close(): void { |
| 1384 | this.#widget?.stop(); |
| 1385 | this.#widget = null; |
| 1386 | this.#typeahead = []; |
| 1387 | this.#detachStatus(); |
| 1388 | } |
| 1389 | |
| 1390 | [Symbol.dispose](): void { |
| 1391 | this.close(); |
| 1392 | } |
| 1393 | } |
| 1394 | |
| 1395 | type Syntax = Pick<Language, "tokenizer" | "styles" | "incomplete" | "complete">; |
| 1396 | |
| 1397 | const indent = " "; |
| 1398 | const menuRows = 6; |
| 1399 | const historyLimit = 1000; |
| 1400 | const commandLine = /^\/([\w-]+)(?:\s+([\s\S]*))?$/; |
| 1401 | const commandWord = /\/([\w-]+)(?!\S)/y; |
| 1402 | /** the tokenizer state of a line that is a command */ |
| 1403 | const commandState = "\0/"; |
| 1404 | |
| 1405 | let segmenter: Intl.Segmenter | null = null; |
| 1406 | |
| 1407 | /** a `language` where a line that names one of `commands` is not code */ |
| 1408 | function withCommands( |
| 1409 | language: Language | null, |
| 1410 | commands: Record<string, Command | CommandExecute>, |
| 1411 | ): Syntax { |
| 1412 | return { |
| 1413 | styles: { command: ansi.fgBlue, ...language?.styles }, |
| 1414 | tokenizer: { |
| 1415 | start: language?.tokenizer.start ?? "", |
| 1416 | next(text, offset, state) { |
| 1417 | if (state === commandState) return { end: text.length, kind: "", state }; |
| 1418 | if (offset === 0) { |
| 1419 | commandWord.lastIndex = 0; |
| 1420 | const name = commandWord.exec(text)?.[1]; |
| 1421 | if (name !== undefined && Object.hasOwn(commands, name)) { |
| 1422 | return { end: name.length + 1, kind: "command", state: commandState }; |
| 1423 | } |
| 1424 | } |
| 1425 | return language?.tokenizer.next(text, offset, state) |
| 1426 | ?? { end: text.length, kind: "", state }; |
| 1427 | }, |
| 1428 | }, |
| 1429 | incomplete: (code, state) => state !== commandState && (language?.incomplete?.(code, state) ?? false), |
| 1430 | complete(code, cursor, asked, kind) { |
| 1431 | const typed = code.slice(0, cursor); |
| 1432 | const names = /^\/[\w-]*$/.test(typed) |
| 1433 | ? Object.keys(commands).filter((command) => /^[\w-]+$/.test(command)).sort() |
| 1434 | : []; |
| 1435 | if (names.length > 0) return { from: 1, options: names }; |
| 1436 | |
| 1437 | const [head, name = ""] = /^\/([\w-]+)\s+/.exec(typed) ?? []; |
| 1438 | if (head === undefined || !Object.hasOwn(commands, name)) { |
| 1439 | return language?.complete?.(code, cursor, asked, kind) ?? null; |
| 1440 | } |
| 1441 | const command = UNWRAP(commands[name]); |
| 1442 | const found = typeof command === "function" ? null : command.complete?.(typed.slice(head.length), asked); |
| 1443 | return found ? { from: head.length + found.from, options: found.options } : null; |
| 1444 | }, |
| 1445 | }; |
| 1446 | } |
| 1447 | |
| 1448 | function readHistory(file: string): string[] { |
| 1449 | const fs = UNWRAP(node.builtin("fs")); |
| 1450 | const path = UNWRAP(node.builtin("path")); |
| 1451 | fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); |
| 1452 | if (!fs.existsSync(file)) return []; |
| 1453 | |
| 1454 | // a line of the file is a JSON string, since entries can span lines |
| 1455 | const entries = fs.readFileSync(file, "utf-8").split("\n").flatMap((line) => { |
| 1456 | try { |
| 1457 | const entry: unknown = JSON.parse(line); |
| 1458 | return typeof entry === "string" ? [entry] : []; |
| 1459 | } catch { |
| 1460 | return []; |
| 1461 | } |
| 1462 | }); |
| 1463 | if (entries.length > historyLimit * 2) { |
| 1464 | entries.splice(0, entries.length - historyLimit); |
| 1465 | const lines = entries.map((entry) => JSON.stringify(entry) + "\n"); |
| 1466 | fs.writeFileSync(file, lines.join(""), { mode: 0o600 }); |
| 1467 | } |
| 1468 | return entries; |
| 1469 | } |
| 1470 | |
| 1471 | function inspect(value: unknown): string { |
| 1472 | return node.builtin("util")?.inspect(value, { colors: true }) ?? String(value); |
| 1473 | } |
| 1474 | |
| 1475 | /** what to print for something that entered code or a key binding threw */ |
| 1476 | function report(error: unknown): string { |
| 1477 | try { |
| 1478 | return error instanceof Error |
| 1479 | ? stack.format(error, true).trimEnd() |
| 1480 | : ansi.style(ansi.fgRed, "uncaught") + " " + inspect(error); |
| 1481 | } catch { |
| 1482 | // the error has something in it that cannot be formatted |
| 1483 | return exceptions.message(error); |
| 1484 | } |
| 1485 | } |
| 1486 | |
| 1487 | function isWord(char: string) { |
| 1488 | // surrogates count so that a pair is never split |
| 1489 | return /[\p{L}\p{N}\p{M}\p{Cs}_$]/u.test(char); |
| 1490 | } |
| 1491 | |
| 1492 | function lineStart(text: string, offset: number) { |
| 1493 | return offset === 0 ? 0 : text.lastIndexOf("\n", offset - 1) + 1; |
| 1494 | } |
| 1495 | |
| 1496 | function lineEnd(text: string, offset: number) { |
| 1497 | const end = text.indexOf("\n", offset); |
| 1498 | return end === -1 ? text.length : end; |
| 1499 | } |
| 1500 | |
| 1501 | /** `offset` within `text`, moved to an edge of the grapheme it is inside of */ |
| 1502 | function snap(text: string, offset: number, step: -1 | 1): number { |
| 1503 | const start = lineStart(text, Math.min(offset, text.length)); |
| 1504 | const end = lineEnd(text, start); |
| 1505 | if (offset <= start || offset >= end) return Math.min(offset, end); |
| 1506 | |
| 1507 | segmenter ??= new Intl.Segmenter(); |
| 1508 | const line = text.slice(start, end); |
| 1509 | const { index, segment } = UNWRAP(segmenter.segment(line).containing(offset - start)); |
| 1510 | if (start + index === offset) return offset; |
| 1511 | return start + index + (step < 0 ? 0 : segment.length); |
| 1512 | } |
| 1513 | |
| 1514 | /** index of the first token that ends after `offset` */ |
| 1515 | function tokenIndex(tokens: Token[], offset: number) { |
| 1516 | let low = 0; |
| 1517 | let high = tokens.length; |
| 1518 | while (low < high) { |
| 1519 | const middle = (low + high) >> 1; |
| 1520 | if (UNWRAP(tokens[middle]).end > offset) high = middle; |
| 1521 | else low = middle + 1; |
| 1522 | } |
| 1523 | return low; |
| 1524 | } |
| 1525 | |
| 1526 | interface Cell { |
| 1527 | offset: number; |
| 1528 | text: string; |
| 1529 | width: number; |
| 1530 | } |
| 1531 | |
| 1532 | /** |
| 1533 | * how the graphemes of `text` between `from` and `to` are displayed, which |
| 1534 | * differs from the text itself for control characters. the range must be |
| 1535 | * within one line. |
| 1536 | */ |
| 1537 | function cells(text: string, from: number, to: number): Cell[] { |
| 1538 | const line = text.slice(from, to); |
| 1539 | if (printableAscii.test(line)) { |
| 1540 | return Array.from(line, (char, i) => ({ offset: from + i, text: char, width: 1 })); |
| 1541 | } |
| 1542 | |
| 1543 | segmenter ??= new Intl.Segmenter(); |
| 1544 | return Array.from(segmenter.segment(line), ({ segment, index }) => { |
| 1545 | const code = segment.charCodeAt(0); |
| 1546 | const offset = from + index; |
| 1547 | if (segment === "\t") return { offset, text: indent, width: indent.length }; |
| 1548 | if (code < 0x20 || (code >= 0x7f && code < 0xa0)) { |
| 1549 | // control pictures stand in for what would otherwise move the cursor |
| 1550 | const picture = code < 0x20 ? 0x2400 + code : code === 0x7f ? 0x2421 : 0xfffd; |
| 1551 | return { offset, text: String.fromCharCode(picture), width: 1 }; |
| 1552 | } |
| 1553 | return { offset, text: segment, width: ansi.widthInTerminal(segment) }; |
| 1554 | }); |
| 1555 | } |
| 1556 | const printableAscii = /^[\x20-\x7e]*$/; |
| 1557 | |
| 1558 | interface Row { |
| 1559 | from: number; |
| 1560 | to: number; |
| 1561 | /** whether this is the first row of its line */ |
| 1562 | head: boolean; |
| 1563 | } |
| 1564 | |
| 1565 | /** break `text` into rows at most `columns` cells wide */ |
| 1566 | function layout(text: string, columns: number): Row[] { |
| 1567 | const rows: Row[] = []; |
| 1568 | for (let start = 0; start <= text.length;) { |
| 1569 | const end = lineEnd(text, start); |
| 1570 | if (printableAscii.test(text.slice(start, end))) { |
| 1571 | for (let from = start; from === start || from < end; from += columns) { |
| 1572 | rows.push({ from, to: Math.min(end, from + columns), head: from === start }); |
| 1573 | } |
| 1574 | } else { |
| 1575 | let from = start; |
| 1576 | let width = 0; |
| 1577 | for (const cell of cells(text, start, end)) { |
| 1578 | if (width + cell.width > columns && cell.offset > from) { |
| 1579 | rows.push({ from, to: cell.offset, head: from === start }); |
| 1580 | from = cell.offset; |
| 1581 | width = 0; |
| 1582 | } |
| 1583 | width += cell.width; |
| 1584 | } |
| 1585 | rows.push({ from, to: end, head: from === start }); |
| 1586 | } |
| 1587 | start = end + 1; |
| 1588 | } |
| 1589 | return rows; |
| 1590 | } |
| 1591 | |
| 1592 | /** the styled lines of `text` */ |
| 1593 | function paint(text: string, tokens: Token[], styles: Record<string, string>): string[] { |
| 1594 | const lines: string[] = []; |
| 1595 | for (let start = 0; start <= text.length;) { |
| 1596 | const end = lineEnd(text, start); |
| 1597 | lines.push(paintLine(text, tokens, styles, start, end)); |
| 1598 | start = end + 1; |
| 1599 | } |
| 1600 | return lines; |
| 1601 | } |
| 1602 | |
| 1603 | /** `text` between `from` and `to`, which are within one line, as displayed */ |
| 1604 | function paintLine( |
| 1605 | text: string, |
| 1606 | tokens: Token[], |
| 1607 | styles: Record<string, string>, |
| 1608 | from: number, |
| 1609 | to: number, |
| 1610 | ): string { |
| 1611 | let out = ""; |
| 1612 | let style = ""; |
| 1613 | let index = tokenIndex(tokens, from); |
| 1614 | for (const cell of cells(text, from, to)) { |
| 1615 | while (index < tokens.length && UNWRAP(tokens[index]).end <= cell.offset) index += 1; |
| 1616 | const next = styles[tokens[index]?.kind ?? ""] ?? ""; |
| 1617 | if (next !== style) out += (style && ansi.reset) + next; |
| 1618 | style = next; |
| 1619 | out += cell.text; |
| 1620 | } |
| 1621 | return out + (style && ansi.reset); |
| 1622 | } |
| 1623 | |
| 1624 | /** a marker in the cells that the prompt occupies on the first row */ |
| 1625 | function margin(gutter: number, mark: string): string { |
| 1626 | if (gutter === 0) return ""; |
| 1627 | return (mark && ansi.style(ansi.dim, mark)) + " ".repeat(gutter - mark.length); |
| 1628 | } |
| 1629 | |
| 1630 | /** |
| 1631 | * where `option` matches `typed`, or null if it does not. a match is loose: |
| 1632 | * whatever the case, the typed characters may go on from one another, or |
| 1633 | * from the start of any later word (`fm` finds `formatMenu`, and `wu` finds |
| 1634 | * `wait-up`). failing that, they may be anywhere, if together. |
| 1635 | */ |
| 1636 | function fuzzy(option: string, typed: string): number[] | null { |
| 1637 | const lower = option.toLowerCase(); |
| 1638 | const wanted = typed.toLowerCase(); |
| 1639 | // the cases of a few characters differ in length, which would shift matches |
| 1640 | if (lower.length !== option.length) return option.startsWith(typed) ? [...typed].map((_, i) => i) : null; |
| 1641 | |
| 1642 | const found: number[] = []; |
| 1643 | for (let i = 0, at = 0; i < wanted.length; i += 1, at += 1) { |
| 1644 | if (i === 0 || lower[at] !== wanted[i]) { |
| 1645 | while (at < lower.length && !(lower[at] === wanted[i] && isWordStart(option, at))) at += 1; |
| 1646 | } |
| 1647 | if (at >= lower.length) { |
| 1648 | const index = lower.indexOf(wanted); |
| 1649 | return index === -1 ? null : Array.from(wanted, (_, offset) => index + offset); |
| 1650 | } |
| 1651 | found.push(at); |
| 1652 | } |
| 1653 | return found; |
| 1654 | } |
| 1655 | |
| 1656 | function isWordStart(text: string, index: number) { |
| 1657 | const before = text[index - 1]; |
| 1658 | if (before === undefined || !/[\p{L}\p{N}]/u.test(before)) return true; |
| 1659 | return /\p{Lu}/u.test(UNWRAP(text[index])) && !/\p{Lu}/u.test(before); |
| 1660 | } |
| 1661 | |
| 1662 | /** |
| 1663 | * the `options` that `typed` matches, best first: those it begins, then those |
| 1664 | * it is found in with the fewest jumps, the earliest. |
| 1665 | */ |
| 1666 | function narrow(options: string[], typed: string): string[] { |
| 1667 | const lower = typed.toLowerCase(); |
| 1668 | const matches = options.flatMap((option) => { |
| 1669 | const found = fuzzy(option, typed); |
| 1670 | if (!found) return []; |
| 1671 | const jumps = found.filter((at, i) => i > 0 && at !== UNWRAP(found[i - 1]) + 1).length; |
| 1672 | const score = option.startsWith(typed) |
| 1673 | ? 0 |
| 1674 | : option.toLowerCase().startsWith(lower) |
| 1675 | ? 1 |
| 1676 | : 2 + jumps * option.length + (found[0] ?? 0); |
| 1677 | return [{ option, score }]; |
| 1678 | }); |
| 1679 | // the sort is stable, which leaves the order they were given in to decide |
| 1680 | return matches.sort((a, b) => a.score - b.score).map((match) => match.option); |
| 1681 | } |
| 1682 | |
| 1683 | /** |
| 1684 | * up to `rows` of the menu's options as a box of one width, the page that |
| 1685 | * has the highlighted option, with what `typed` matched picked out. |
| 1686 | */ |
| 1687 | function formatMenu(menu: Menu, typed: string, rows: number): { lines: string[]; width: number } { |
| 1688 | const widest = menu.options |
| 1689 | .reduce((widest, option) => Math.max(widest, ansi.widthInTerminal(option)), 0); |
| 1690 | const first = menu.selected - menu.selected % rows; |
| 1691 | const lines = menu.options.slice(first, first + rows).map((option, i) => { |
| 1692 | let label = ""; |
| 1693 | let from = 0; |
| 1694 | for (const at of fuzzy(option, typed) ?? []) { |
| 1695 | label += option.slice(from, at) + menuMatch[0] + option[at] + menuMatch[1]; |
| 1696 | from = at + 1; |
| 1697 | } |
| 1698 | label += option.slice(from); |
| 1699 | const padding = " ".repeat(widest - ansi.widthInTerminal(option) + 1); |
| 1700 | return (first + i === menu.selected ? menuChosen : surface) + " " + label + padding + ansi.reset; |
| 1701 | }); |
| 1702 | return { lines, width: widest + 2 }; |
| 1703 | } |
| 1704 | |
| 1705 | /** what a menu is drawn on */ |
| 1706 | const surface = ansi.bgBrightBlack + ansi.fgBrightWhite; |
| 1707 | /** what a filled prompt is drawn on: a grey that is dark as a block, which bright black is as text */ |
| 1708 | const filled = ansi.bg256(237) + ansi.fgBrightWhite; |
| 1709 | /** the highlighted option of a menu, and around a matched character */ |
| 1710 | const menuChosen = ansi.bgBlue + ansi.fgBrightWhite; |
| 1711 | const menuMatch = [ansi.bold + ansi.fgBrightYellow, ansi.resetWeight + ansi.fgBrightWhite] as const; |
| 1712 | |
| 1713 | /** colors for the kinds of token that {@linkcode javascriptTokenizer} produces */ |
| 1714 | const javascriptStyles: Record<string, string> = { |
| 1715 | keyword: ansi.fgMagenta, |
| 1716 | literal: ansi.fgYellow, |
| 1717 | number: ansi.fgYellow, |
| 1718 | string: ansi.fgGreen, |
| 1719 | regexp: ansi.fgCyan, |
| 1720 | comment: ansi.fgBrightBlack, |
| 1721 | invalid: ansi.fgRed, |
| 1722 | }; |
| 1723 | |
| 1724 | /** |
| 1725 | * lexes javascript into the kinds `keyword`, `literal`, `number`, `string`, |
| 1726 | * `regexp`, `comment`, `punctuation`, `property`, `variable`, `invalid`, and |
| 1727 | * `""` for whitespace. |
| 1728 | * |
| 1729 | * the state is a stack of what is open, one character each: `(`, `[`, `{`, a |
| 1730 | * backtick for template text, `$` for a template substitution, and `*` for a |
| 1731 | * block comment. it is preceded by `!` once a bracket was mismatched, and |
| 1732 | * followed by what the next token follows: `r` if a slash would start a |
| 1733 | * regular expression, `d` if it would divide, `.` if a name would be a |
| 1734 | * property. |
| 1735 | */ |
| 1736 | export const javascriptTokenizer: Tokenizer = { |
| 1737 | start: "r", |
| 1738 | next(text, offset, state) { |
| 1739 | const stack = state.slice(0, -1); |
| 1740 | const follows = state[state.length - 1]!; |
| 1741 | const top = stack[stack.length - 1]; |
| 1742 | const token = (end: number, kind: string, next = state): Token => ({ |
| 1743 | // an expression that matches nothing still has to make progress |
| 1744 | end: end > offset ? end : offset + (text.codePointAt(offset)! > 0xffff ? 2 : 1), |
| 1745 | kind, |
| 1746 | state: next, |
| 1747 | }); |
| 1748 | |
| 1749 | if (top === "`") { |
| 1750 | if (text.startsWith("${", offset)) return token(offset + 2, "punctuation", stack + "$r"); |
| 1751 | if (text[offset] === "`") return token(offset + 1, "string", stack.slice(0, -1) + "d"); |
| 1752 | return token(match(templateText, text, offset), "string"); |
| 1753 | } |
| 1754 | |
| 1755 | const char = text[offset]!; |
| 1756 | const space = match(whitespace, text, offset); |
| 1757 | if (space !== -1) return token(space, ""); |
| 1758 | |
| 1759 | if (char === "/") { |
| 1760 | const after = text[offset + 1]; |
| 1761 | if (after === "/") return token(match(lineComment, text, offset), "comment"); |
| 1762 | if (after === "*") { |
| 1763 | const close = text.indexOf("*/", offset + 2); |
| 1764 | return close === -1 |
| 1765 | ? token(text.length, "comment", stack + "*" + follows) |
| 1766 | : token(close + 2, "comment"); |
| 1767 | } |
| 1768 | // an unterminated one runs to the end of the line, like a string, so |
| 1769 | // that the token never depends on text beyond itself |
| 1770 | const end = follows === "r" ? match(regexp, text, offset) : -1; |
| 1771 | if (end !== -1) return token(end, "regexp", stack + "d"); |
| 1772 | } |
| 1773 | |
| 1774 | if (char === "'" || char === "\"") { |
| 1775 | return token(match(quoted, text, offset), "string", stack + "d"); |
| 1776 | } |
| 1777 | if (char === "`") return token(offset + 1, "string", stack + "`" + follows); |
| 1778 | |
| 1779 | const digits = match(numeral, text, offset); |
| 1780 | if (digits !== -1) return token(digits, "number", stack + "d"); |
| 1781 | |
| 1782 | const end = match(word, text, offset); |
| 1783 | if (end !== -1) { |
| 1784 | const name = text.slice(offset, end); |
| 1785 | if (follows === ".") return token(end, "property", stack + "d"); |
| 1786 | if (literals.has(name)) return token(end, "literal", stack + "d"); |
| 1787 | if (!keywords.has(name)) return token(end, "variable", stack + "d"); |
| 1788 | return token(end, "keyword", stack + (name === "this" || name === "super" ? "d" : "r")); |
| 1789 | } |
| 1790 | |
| 1791 | if ("([{".includes(char)) return token(offset + 1, "punctuation", stack + char + "r"); |
| 1792 | const closes = closers[char]; |
| 1793 | if (closes) { |
| 1794 | // after a block a slash starts a regular expression. after an object |
| 1795 | // literal it divides, which is rare enough to get wrong. |
| 1796 | const popped = stack.slice(0, -1); |
| 1797 | if (top === closes) return token(offset + 1, "punctuation", popped + (char === "}" ? "r" : "d")); |
| 1798 | if (top === "$" && char === "}") return token(offset + 1, "punctuation", popped + "d"); |
| 1799 | return token(offset + 1, "invalid", (stack.startsWith("!") ? "" : "!") + stack + "d"); |
| 1800 | } |
| 1801 | |
| 1802 | const operator = match(punctuation, text, offset); |
| 1803 | if (operator !== -1) { |
| 1804 | const symbol = text.slice(offset, operator); |
| 1805 | const next = symbol === "." || symbol === "?." |
| 1806 | ? "." |
| 1807 | // these attach to an operand on either side, so they change nothing |
| 1808 | : symbol === "++" || symbol === "--" |
| 1809 | ? follows |
| 1810 | : "r"; |
| 1811 | return token(operator, "punctuation", stack + next); |
| 1812 | } |
| 1813 | return token(offset, "invalid"); |
| 1814 | }, |
| 1815 | }; |
| 1816 | |
| 1817 | const keywords = new Set( |
| 1818 | "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" |
| 1819 | .split(" "), |
| 1820 | ); |
| 1821 | const literals = new Set(["true", "false", "null", "undefined", "NaN", "Infinity"]); |
| 1822 | const closers: Partial<Record<string, string>> = { ")": "(", "]": "[", "}": "{" }; |
| 1823 | |
| 1824 | const whitespace = /\s+/y; |
| 1825 | const lineComment = /\/\/[^\n]*/y; |
| 1826 | const word = /#?[\p{ID_Start}$_][\p{ID_Continue}$\u200c\u200d]*/uy; |
| 1827 | // an exponent may be missing its digits, so that the token does not depend on |
| 1828 | // what follows a sign that would otherwise be a token of its own |
| 1829 | const numeral = |
| 1830 | /0[xX][\da-fA-F_]+n?|0[oO][0-7_]+n?|0[bB][01_]+n?|(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][+-]?[\d_]*)?n?/y; |
| 1831 | const quoted = /'(?:[^'\\\n]|\\[\s\S])*'?|"(?:[^"\\\n]|\\[\s\S])*"?/y; |
| 1832 | const templateText = /(?:[^`\\$]|\\[\s\S]|\$(?!\{))+/y; |
| 1833 | const regexp = /\/(?:[^\/\\\[\n]|\\[^\n]|\[(?:[^\]\\\n]|\\[^\n])*\]?)+(?:\/[\p{ID_Continue}$]*)?/uy; |
| 1834 | const punctuation = |
| 1835 | />>>=|\.\.\.|===|!==|\*\*=|<<=|>>=|>>>|&&=|\|\|=|\?\?=|=>|==|!=|<=|>=|&&|\|\||\?\?|\?\.(?!\d)|\+\+|--|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<|>>|\*\*|[-+*\/%&|^!~<>=?:;,.@]/y; |
| 1836 | |
| 1837 | const lineTerminator = /\r\n|[\n\r\u2028\u2029]/; |
| 1838 | const identifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u; |
| 1839 | /** a chain of property accesses, then the name being typed */ |
| 1840 | const completable = /((?:[\p{ID_Start}$_][\p{ID_Continue}$]*\s*\??\.\s*)*)([\p{ID_Start}$_][\p{ID_Continue}$]*)?$/u; |
| 1841 | |
| 1842 | /** where `expression` stops matching at `offset`, or -1 */ |
| 1843 | function match(expression: RegExp, text: string, offset: number): number { |
| 1844 | expression.lastIndex = offset; |
| 1845 | return expression.test(text) ? expression.lastIndex : -1; |
| 1846 | } |
| 1847 | |
| 1848 | /** |
| 1849 | * the names of an object's properties, its own before those it inherits. |
| 1850 | * runs both here and, through its source text, inside inspector evaluations. |
| 1851 | * index keys are left out, as they are never valid after a dot. |
| 1852 | */ |
| 1853 | function propertyNames(value: unknown): string[] { |
| 1854 | const names = []; |
| 1855 | for ( |
| 1856 | let object = value == null ? null : Object(value); |
| 1857 | object; |
| 1858 | object = Object.getPrototypeOf(object) |
| 1859 | ) { |
| 1860 | for (const name of Object.getOwnPropertyNames(object).sort()) { |
| 1861 | const code = name.charCodeAt(0); |
| 1862 | if (code < 48 || code > 57) names.push(name); |
| 1863 | } |
| 1864 | } |
| 1865 | return names; |
| 1866 | } |
| 1867 | |
| 1868 | /** |
| 1869 | * code evaluated through the inspector reaches this module by a global. |
| 1870 | * the key is unique to this copy of the library. |
| 1871 | */ |
| 1872 | const bridge = { |
| 1873 | take(value: unknown): void { |
| 1874 | taken = value; |
| 1875 | }, |
| 1876 | /** |
| 1877 | * scripts compiled by the inspector cannot `import()`, so the keyword is |
| 1878 | * replaced by a call to this, which resolves relative paths from the |
| 1879 | * working directory, as there is no importing file. |
| 1880 | */ |
| 1881 | import(specifier: string, options?: { with?: Record<string, string> }): Promise<unknown> { |
| 1882 | if (/^\.\.?\//.test(specifier)) { |
| 1883 | const file = UNWRAP(node.builtin("path")).resolve(specifier); |
| 1884 | specifier = UNWRAP(node.builtin("url")).pathToFileURL(file).href; |
| 1885 | } |
| 1886 | return import(specifier, options); |
| 1887 | }, |
| 1888 | }; |
| 1889 | const bridgeSource = `globalThis[Symbol.for(${JSON.stringify(import.meta.url)})]`; |
| 1890 | let taken: unknown; |
| 1891 | |
| 1892 | function bridgeImports(code: string): string { |
| 1893 | const tokens: (Token & { start: number })[] = []; |
| 1894 | let state = javascriptTokenizer.start; |
| 1895 | for (let offset = 0; offset < code.length;) { |
| 1896 | const token = javascriptTokenizer.next(code, offset, state); |
| 1897 | if (token.kind !== "" && token.kind !== "comment") tokens.push({ ...token, start: offset }); |
| 1898 | offset = token.end; |
| 1899 | state = token.state; |
| 1900 | } |
| 1901 | const text = (token = { start: 0, end: 0 }) => code.slice(token.start, token.end); |
| 1902 | |
| 1903 | let out = ""; |
| 1904 | let copied = 0; |
| 1905 | for (const [i, token] of tokens.entries()) { |
| 1906 | if (token.kind !== "keyword" || text(token) !== "import" || text(tokens[i + 1]) !== "(") { |
| 1907 | continue; |
| 1908 | } |
| 1909 | // a method that is named import has a body after its parameters |
| 1910 | const close = tokens.findIndex((other, at) => at > i + 1 && other.state.length <= token.state.length); |
| 1911 | if (close !== -1 && text(tokens[close + 1]) === "{") continue; |
| 1912 | |
| 1913 | out += code.slice(copied, token.start) + bridgeSource + ".import"; |
| 1914 | copied = token.end; |
| 1915 | } |
| 1916 | return out + code.slice(copied); |
| 1917 | } |
| 1918 | |
| 1919 | /** a RemoteObject of the Chrome DevTools Protocol */ |
| 1920 | interface Remote { |
| 1921 | objectId?: string; |
| 1922 | unserializableValue?: string; |
| 1923 | value?: unknown; |
| 1924 | } |
| 1925 | |
| 1926 | /** replies are typed `any`, as their shape depends on the method */ |
| 1927 | interface Inspector { |
| 1928 | /** the reply to a method that answers before returning, as most do */ |
| 1929 | ask(method: string, params: object): any; |
| 1930 | evaluate(params: object): Promise<any>; |
| 1931 | /** the value that a RemoteObject stands for */ |
| 1932 | take(remote: Remote): unknown; |
| 1933 | } |
| 1934 | |
| 1935 | /** an inspector session into this same thread, if the runtime has a usable one */ |
| 1936 | function connect(): Inspector | null { |
| 1937 | try { |
| 1938 | const session = new (UNWRAP(node.builtin("inspector")).Session)(); |
| 1939 | session.connect(); |
| 1940 | |
| 1941 | const ask: Inspector["ask"] = (method, params) => { |
| 1942 | let reply; |
| 1943 | session.post(method, params, (_, result) => { |
| 1944 | reply = result; |
| 1945 | }); |
| 1946 | return reply; |
| 1947 | }; |
| 1948 | // deno has the module, but answers later |
| 1949 | if (ask("Runtime.evaluate", { expression: "0" })?.result?.value !== 0) return null; |
| 1950 | |
| 1951 | Object.defineProperty(globalThis, Symbol.for(import.meta.url), { |
| 1952 | value: bridge, |
| 1953 | configurable: true, |
| 1954 | }); |
| 1955 | return { |
| 1956 | ask, |
| 1957 | evaluate: (params) => |
| 1958 | new Promise((resolve, reject) => { |
| 1959 | session.post("Runtime.evaluate", params, (error, result) => { |
| 1960 | if (error) reject(error); |
| 1961 | else resolve(result); |
| 1962 | }); |
| 1963 | }), |
| 1964 | take(remote) { |
| 1965 | if (remote.objectId) { |
| 1966 | ask("Runtime.callFunctionOn", { |
| 1967 | objectId: remote.objectId, |
| 1968 | functionDeclaration: `function(){"use strict";${bridgeSource}.take(this)}`, |
| 1969 | }); |
| 1970 | const value = taken; |
| 1971 | taken = undefined; |
| 1972 | return value; |
| 1973 | } |
| 1974 | const literal = remote.unserializableValue; |
| 1975 | if (literal === undefined) return remote.value; |
| 1976 | return literal.endsWith("n") ? BigInt(literal.slice(0, -1)) : Number(literal); |
| 1977 | }, |
| 1978 | }; |
| 1979 | } catch { |
| 1980 | return null; |
| 1981 | } |
| 1982 | } |
| 1983 | |
| 1984 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1985 | import * as exceptions from "./exception.ts"; |
| 1986 | import * as log from "./log.ts"; |
| 1987 | import * as stack from "./log/stack.ts"; |
| 1988 | import * as node from "./node.ts"; |
| 1989 | import * as progress from "./progress.ts"; |
| 1990 | import * as string from "./string.ts"; |
| 1991 | import * as ansi from "./string/ansi.ts"; |
| 1992 | import * as ts from "./ts.ts"; |