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} */
30export 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 */
68export type Style = "plain" | "lines" | "filled";
69
70export type CommandExecute = (this: Repl, args: string, node: progress.Node) => Promise<void> | void;
71export 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 */
91export 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 */
102export 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
135export 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 */
144export 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
156export 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} */
166export 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 */
187export 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 */
315export 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 */
333export 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 */
367export 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
411type Request =
412 | { id: number; eval: string }
413 | { id: number; complete: Parameters<NonNullable<Language["complete"]>> };
414type 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 */
421const wire = "@clo/lib/repl";
422
423/** send a message, to hear through `sent` whether it could be. node.js would otherwise throw from nowhere. */
424function 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
429function 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 */
436function formatted(text: string): unknown {
437 return { [Symbol.for("nodejs.util.inspect.custom")]: () => text, toString: () => text };
438}
439
440/** lex all of `text` */
441export 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 */
454export 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 */
497export 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} */
505export 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 */
523export 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
953interface 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 */
966export type Binding = EditorAction | ((editor: Editor, repl: Repl) => void);
967type 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 */
979export 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
1037function interrupt(editor: Editor, repl: Repl) {
1038 if (editor.text || editor.search) editor.clear();
1039 else repl.interrupt();
1040}
1041
1042function suspend(_: Editor, repl: Repl) {
1043 repl.suspend();
1044}
1045
1046/** what to draw an {@linkcode Editor} into */
1047export 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
1063export 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 */
1075export 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 */
1167export 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
1395type Syntax = Pick<Language, "tokenizer" | "styles" | "incomplete" | "complete">;
1396
1397const indent = " ";
1398const menuRows = 6;
1399const historyLimit = 1000;
1400const commandLine = /^\/([\w-]+)(?:\s+([\s\S]*))?$/;
1401const commandWord = /\/([\w-]+)(?!\S)/y;
1402/** the tokenizer state of a line that is a command */
1403const commandState = "\0/";
1404
1405let segmenter: Intl.Segmenter | null = null;
1406
1407/** a `language` where a line that names one of `commands` is not code */
1408function 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
1448function 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
1471function 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 */
1476function 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
1487function 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
1492function lineStart(text: string, offset: number) {
1493 return offset === 0 ? 0 : text.lastIndexOf("\n", offset - 1) + 1;
1494}
1495
1496function 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 */
1502function 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` */
1515function 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
1526interface 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 */
1537function 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}
1556const printableAscii = /^[\x20-\x7e]*$/;
1557
1558interface 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 */
1566function 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` */
1593function 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 */
1604function 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 */
1625function 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 */
1636function 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
1656function 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 */
1666function 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 */
1687function 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 */
1706const 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 */
1708const filled = ansi.bg256(237) + ansi.fgBrightWhite;
1709/** the highlighted option of a menu, and around a matched character */
1710const menuChosen = ansi.bgBlue + ansi.fgBrightWhite;
1711const menuMatch = [ansi.bold + ansi.fgBrightYellow, ansi.resetWeight + ansi.fgBrightWhite] as const;
1712
1713/** colors for the kinds of token that {@linkcode javascriptTokenizer} produces */
1714const 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 */
1736export 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
1817const 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);
1821const literals = new Set(["true", "false", "null", "undefined", "NaN", "Infinity"]);
1822const closers: Partial<Record<string, string>> = { ")": "(", "]": "[", "}": "{" };
1823
1824const whitespace = /\s+/y;
1825const lineComment = /\/\/[^\n]*/y;
1826const 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
1829const 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;
1831const quoted = /'(?:[^'\\\n]|\\[\s\S])*'?|"(?:[^"\\\n]|\\[\s\S])*"?/y;
1832const templateText = /(?:[^`\\$]|\\[\s\S]|\$(?!\{))+/y;
1833const regexp = /\/(?:[^\/\\\[\n]|\\[^\n]|\[(?:[^\]\\\n]|\\[^\n])*\]?)+(?:\/[\p{ID_Continue}$]*)?/uy;
1834const punctuation =
1835 />>>=|\.\.\.|===|!==|\*\*=|<<=|>>=|>>>|&&=|\|\|=|\?\?=|=>|==|!=|<=|>=|&&|\|\||\?\?|\?\.(?!\d)|\+\+|--|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<|>>|\*\*|[-+*\/%&|^!~<>=?:;,.@]/y;
1836
1837const lineTerminator = /\r\n|[\n\r\u2028\u2029]/;
1838const identifier = /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u;
1839/** a chain of property accesses, then the name being typed */
1840const 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 */
1843function 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 */
1853function 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 */
1872const 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};
1889const bridgeSource = `globalThis[Symbol.for(${JSON.stringify(import.meta.url)})]`;
1890let taken: unknown;
1891
1892function 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 */
1920interface Remote {
1921 objectId?: string;
1922 unserializableValue?: string;
1923 value?: unknown;
1924}
1925
1926/** replies are typed `any`, as their shape depends on the method */
1927interface 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 */
1936function 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
1984import { ASSERT, UNWRAP } from "./assert.ts";
1985import * as exceptions from "./exception.ts";
1986import * as log from "./log.ts";
1987import * as stack from "./log/stack.ts";
1988import * as node from "./node.ts";
1989import * as progress from "./progress.ts";
1990import * as string from "./string.ts";
1991import * as ansi from "./string/ansi.ts";
1992import * as ts from "./ts.ts";