Skip to content

Commit e76e5fa

Browse files
bartlomiejuclaude
andauthored
fix(ext/node): multiple readline improvements (#32538)
## Summary Several fixes and feature additions to the Node.js readline polyfill: - **Fix tab completion column formatting** — `Math.max.apply(completionsWidth)` was missing the array argument, causing all completions to display one-per-line instead of in columns - **Fix `historySize` validation** — now throws `ERR_OUT_OF_RANGE` for negative/NaN values and `ERR_INVALID_ARG_TYPE` for non-number values (was incorrectly throwing `ERR_INVALID_ARG_VALUE` for all) - **Implement kill ring** with yank (`Ctrl+Y`) and yank-pop (`Meta+Y`) - **Implement undo/redo** (`Ctrl+_` / `Ctrl+^`) - **Fix `write()` after `close()`** to throw `ERR_USE_AFTER_CLOSE` - **Fix `question()` abort signal cleanup** — `removeEventListener` was missing the event name arg, and the callback wrapper had an infinite recursion bug Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 881f8c0 commit e76e5fa

2 files changed

Lines changed: 116 additions & 10 deletions

File tree

ext/node/polyfills/_readline.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,13 @@ Interface.prototype.question = function question(query, options, cb) {
133133
};
134134
options.signal.addEventListener("abort", onAbort, { once: true });
135135
const cleanup = () => {
136-
options.signal.removeEventListener(onAbort);
136+
options.signal.removeEventListener("abort", onAbort);
137137
};
138-
cb = typeof cb === "function"
138+
const originalCb = cb;
139+
cb = typeof originalCb === "function"
139140
? (answer) => {
140141
cleanup();
141-
return cb(answer);
142+
return originalCb(answer);
142143
}
143144
: cleanup;
144145
}

ext/node/polyfills/internal/readline/interface.mjs

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
import { op_get_env_no_permission_check } from "ext:core/ops";
2828

2929
import {
30+
ERR_INVALID_ARG_TYPE,
3031
ERR_INVALID_ARG_VALUE,
32+
ERR_OUT_OF_RANGE,
3133
ERR_USE_AFTER_CLOSE,
3234
} from "ext:deno_node/internal/errors.ts";
3335
import {
@@ -118,6 +120,15 @@ const lineEnding = new SafeRegExp(/\r?\n|\r(?!\n)|\u2028|\u2029/g);
118120
const kLineObjectStream = Symbol("line object stream");
119121
export const kQuestionCancel = Symbol("kQuestionCancel");
120122
export const kQuestion = Symbol("kQuestion");
123+
const kKillRing = Symbol("kKillRing");
124+
const kKillRingCursor = Symbol("kKillRingCursor");
125+
const kYank = Symbol("kYank");
126+
const kYankPop = Symbol("kYankPop");
127+
const kUndoStack = Symbol("kUndoStack");
128+
const kRedoStack = Symbol("kRedoStack");
129+
const kUndo = Symbol("kUndo");
130+
const kRedo = Symbol("kRedo");
131+
const kPushToUndoStack = Symbol("kPushToUndoStack");
121132

122133
// GNU readline library - keyseq-timeout is 500ms (default)
123134
const ESCAPE_CODE_TIMEOUT = 500;
@@ -223,12 +234,12 @@ export function InterfaceConstructor(input, output, completer, terminal) {
223234
historySize = kHistorySize;
224235
}
225236

226-
if (
227-
typeof historySize !== "number" ||
228-
Number.isNaN(historySize) ||
229-
historySize < 0
230-
) {
231-
throw new ERR_INVALID_ARG_VALUE.RangeError("historySize", historySize);
237+
if (typeof historySize !== "number") {
238+
throw new ERR_INVALID_ARG_TYPE("historySize", "number", historySize);
239+
}
240+
241+
if (Number.isNaN(historySize) || historySize < 0) {
242+
throw new ERR_OUT_OF_RANGE("historySize", ">= 0", historySize);
232243
}
233244

234245
// Backwards compat; check the isTTY prop of the output stream
@@ -250,6 +261,10 @@ export function InterfaceConstructor(input, output, completer, terminal) {
250261
? Math.max(kMincrlfDelay, crlfDelay)
251262
: kMincrlfDelay;
252263
this.completer = completer;
264+
this[kKillRing] = [];
265+
this[kKillRingCursor] = 0;
266+
this[kUndoStack] = [];
267+
this[kRedoStack] = [];
253268

254269
this.setPrompt(prompt);
255270

@@ -581,6 +596,9 @@ export class Interface extends InterfaceConstructor {
581596
* @returns {void}
582597
*/
583598
write(d, key) {
599+
if (this.closed) {
600+
throw new ERR_USE_AFTER_CLOSE("readline");
601+
}
584602
if (this.paused) this.resume();
585603
if (this.terminal) {
586604
this[kTtyWrite](d, key);
@@ -709,7 +727,7 @@ export class Interface extends InterfaceConstructor {
709727
const completionsWidth = completions.map(
710728
(e) => getStringWidth(e),
711729
);
712-
const width = Math.max.apply(completionsWidth) + 2; // 2 space padding
730+
const width = Math.max(...completionsWidth) + 2; // 2 space padding
713731
let maxColumns = Math.floor(this.columns / width) || 1;
714732
if (maxColumns === Infinity) {
715733
maxColumns = 1;
@@ -762,6 +780,7 @@ export class Interface extends InterfaceConstructor {
762780

763781
[kDeleteLeft]() {
764782
if (this.cursor > 0 && this.line.length > 0) {
783+
this[kPushToUndoStack]();
765784
// The number of UTF-16 units comprising the character to the left
766785
const charSize = charLengthLeft(this.line, this.cursor);
767786
this.line = this.line.slice(0, this.cursor - charSize) +
@@ -774,6 +793,7 @@ export class Interface extends InterfaceConstructor {
774793

775794
[kDeleteRight]() {
776795
if (this.cursor < this.line.length) {
796+
this[kPushToUndoStack]();
777797
// The number of UTF-16 units comprising the character to the left
778798
const charSize = charLengthAt(this.line, this.cursor);
779799
this.line = this.line.slice(0, this.cursor) +
@@ -787,6 +807,7 @@ export class Interface extends InterfaceConstructor {
787807

788808
[kDeleteWordLeft]() {
789809
if (this.cursor > 0) {
810+
this[kPushToUndoStack]();
790811
// Reverse the string and match a word near beginning
791812
// to avoid quadratic time complexity
792813
let leading = this.line.slice(0, this.cursor);
@@ -796,34 +817,100 @@ export class Interface extends InterfaceConstructor {
796817
0,
797818
leading.length - match[0].length,
798819
);
820+
const killed = this.line.slice(leading.length, this.cursor);
799821
this.line = leading +
800822
this.line.slice(this.cursor, this.line.length);
801823
this.cursor = leading.length;
824+
this[kKillRing].push(killed);
825+
this[kKillRingCursor] = this[kKillRing].length - 1;
802826
this[kRefreshLine]();
803827
}
804828
}
805829

806830
[kDeleteWordRight]() {
807831
if (this.cursor < this.line.length) {
832+
this[kPushToUndoStack]();
808833
const trailing = this.line.slice(this.cursor);
809834
const match = trailing.match(/^(?:\s+|\W+|\w+)\s*/);
835+
const killed = trailing.slice(0, match[0].length);
810836
this.line = this.line.slice(0, this.cursor) +
811837
trailing.slice(match[0].length);
838+
this[kKillRing].push(killed);
839+
this[kKillRingCursor] = this[kKillRing].length - 1;
812840
this[kRefreshLine]();
813841
}
814842
}
815843

816844
[kDeleteLineLeft]() {
845+
this[kPushToUndoStack]();
846+
const killed = this.line.slice(0, this.cursor);
817847
this.line = this.line.slice(this.cursor);
818848
this.cursor = 0;
849+
this[kKillRing].push(killed);
850+
this[kKillRingCursor] = this[kKillRing].length - 1;
819851
this[kRefreshLine]();
820852
}
821853

822854
[kDeleteLineRight]() {
855+
this[kPushToUndoStack]();
856+
const killed = this.line.slice(this.cursor);
823857
this.line = this.line.slice(0, this.cursor);
858+
this[kKillRing].push(killed);
859+
this[kKillRingCursor] = this[kKillRing].length - 1;
824860
this[kRefreshLine]();
825861
}
826862

863+
[kYank]() {
864+
if (this[kKillRing].length > 0) {
865+
this[kKillRingCursor] = this[kKillRing].length - 1;
866+
const killed = this[kKillRing][this[kKillRingCursor]];
867+
this[kInsertString](killed);
868+
}
869+
}
870+
871+
[kYankPop]() {
872+
if (this[kKillRing].length > 1) {
873+
// Remove previously yanked text
874+
const prev = this[kKillRing][this[kKillRingCursor]];
875+
this.line = this.line.slice(0, this.cursor - prev.length) +
876+
this.line.slice(this.cursor);
877+
this.cursor -= prev.length;
878+
879+
// Cycle to previous entry in kill ring
880+
this[kKillRingCursor]--;
881+
if (this[kKillRingCursor] < 0) {
882+
this[kKillRingCursor] = this[kKillRing].length - 1;
883+
}
884+
const killed = this[kKillRing][this[kKillRingCursor]];
885+
this[kInsertString](killed);
886+
}
887+
}
888+
889+
[kPushToUndoStack]() {
890+
this[kUndoStack].push({ line: this.line, cursor: this.cursor });
891+
this[kRedoStack] = [];
892+
}
893+
894+
[kUndo]() {
895+
if (this[kUndoStack].length > 0) {
896+
this[kRedoStack].push({ line: this.line, cursor: this.cursor });
897+
const state = this[kUndoStack].pop();
898+
this.line = state.line;
899+
this.cursor = state.cursor;
900+
this[kRefreshLine]();
901+
}
902+
}
903+
904+
[kRedo]() {
905+
if (this[kRedoStack].length > 0) {
906+
this[kUndoStack].push({ line: this.line, cursor: this.cursor });
907+
const state = this[kRedoStack].pop();
908+
this.line = state.line;
909+
this.cursor = state.cursor;
910+
this[kRefreshLine]();
911+
}
912+
}
913+
827914
clearLine() {
828915
this[kMoveCursor](+Infinity);
829916
this[kWriteToOutput]("\r\n");
@@ -991,6 +1078,16 @@ export class Interface extends InterfaceConstructor {
9911078
}
9921079
}
9931080

1081+
// Undo (Ctrl+_) and Redo (Ctrl+^)
1082+
if (key.sequence === "\x1F") {
1083+
this[kUndo]();
1084+
return;
1085+
}
1086+
if (key.sequence === "\x1E") {
1087+
this[kRedo]();
1088+
return;
1089+
}
1090+
9941091
// Ignore escape key, fixes
9951092
// https://github.com/nodejs/node-v0.x-archive/issues/2876.
9961093
if (key.name === "escape") return;
@@ -1072,6 +1169,10 @@ export class Interface extends InterfaceConstructor {
10721169
this[kHistoryPrev]();
10731170
break;
10741171

1172+
case "y": // Yank killed text
1173+
this[kYank]();
1174+
break;
1175+
10751176
case "z":
10761177
if (process.platform === "win32") break;
10771178
if (this.listenerCount("SIGTSTP") > 0) {
@@ -1136,6 +1237,10 @@ export class Interface extends InterfaceConstructor {
11361237
case "backspace": // Delete backwards to a word boundary
11371238
this[kDeleteWordLeft]();
11381239
break;
1240+
1241+
case "y": // Yank pop
1242+
this[kYankPop]();
1243+
break;
11391244
}
11401245
} else {
11411246
/* No modifier keys used */

0 commit comments

Comments
 (0)