Skip to content

Commit 8122d24

Browse files
committed
readline: introduce promise-based API
PR-URL: #37947 Fixes: #37287 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: Robert Nagy <[email protected]>
1 parent 592d1c3 commit 8122d24

File tree

8 files changed

+1963
-52
lines changed

8 files changed

+1963
-52
lines changed

doc/api/readline.md

+417-50
Large diffs are not rendered by default.

lib/internal/readline/promises.js

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypeJoin,
5+
ArrayPrototypePush,
6+
Promise,
7+
} = primordials;
8+
9+
const { CSI } = require('internal/readline/utils');
10+
const { validateInteger } = require('internal/validators');
11+
const { isWritable } = require('internal/streams/utils');
12+
const { codes: { ERR_INVALID_ARG_TYPE } } = require('internal/errors');
13+
14+
const {
15+
kClearToLineBeginning,
16+
kClearToLineEnd,
17+
kClearLine,
18+
kClearScreenDown,
19+
} = CSI;
20+
21+
class Readline {
22+
#stream;
23+
#todo = [];
24+
25+
constructor(stream) {
26+
if (!isWritable(stream))
27+
throw new ERR_INVALID_ARG_TYPE('stream', 'Writable', stream);
28+
this.#stream = stream;
29+
}
30+
31+
/**
32+
* Moves the cursor to the x and y coordinate on the given stream.
33+
* @param {integer} x
34+
* @param {integer} [y]
35+
* @returns {Readline} this
36+
*/
37+
cursorTo(x, y = undefined) {
38+
validateInteger(x, 'x');
39+
if (y != null) validateInteger(y, 'y');
40+
41+
ArrayPrototypePush(
42+
this.#todo,
43+
y == null ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`
44+
);
45+
46+
return this;
47+
}
48+
49+
/**
50+
* Moves the cursor relative to its current location.
51+
* @param {integer} dx
52+
* @param {integer} dy
53+
* @returns {Readline} this
54+
*/
55+
moveCursor(dx, dy) {
56+
if (dx || dy) {
57+
validateInteger(dx, 'dx');
58+
validateInteger(dy, 'dy');
59+
60+
let data = '';
61+
62+
if (dx < 0) {
63+
data += CSI`${-dx}D`;
64+
} else if (dx > 0) {
65+
data += CSI`${dx}C`;
66+
}
67+
68+
if (dy < 0) {
69+
data += CSI`${-dy}A`;
70+
} else if (dy > 0) {
71+
data += CSI`${dy}B`;
72+
}
73+
ArrayPrototypePush(this.#todo, data);
74+
}
75+
return this;
76+
}
77+
78+
/**
79+
* Clears the current line the cursor is on.
80+
* @param {-1|0|1} dir Direction to clear:
81+
* -1 for left of the cursor
82+
* +1 for right of the cursor
83+
* 0 for the entire line
84+
* @returns {Readline} this
85+
*/
86+
clearLine(dir) {
87+
validateInteger(dir, 'dir', -1, 1);
88+
89+
ArrayPrototypePush(
90+
this.#todo,
91+
dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine
92+
);
93+
return this;
94+
}
95+
96+
/**
97+
* Clears the screen from the current position of the cursor down.
98+
* @returns {Readline} this
99+
*/
100+
clearScreenDown() {
101+
ArrayPrototypePush(this.#todo, kClearScreenDown);
102+
return this;
103+
}
104+
105+
/**
106+
* Sends all the pending actions to the associated `stream` and clears the
107+
* internal list of pending actions.
108+
* @returns {Promise<void>} Resolves when all pending actions have been
109+
* flushed to the associated `stream`.
110+
*/
111+
commit() {
112+
return new Promise((resolve) => {
113+
this.#stream.write(ArrayPrototypeJoin(this.#todo, ''), resolve);
114+
this.#todo = [];
115+
});
116+
}
117+
118+
/**
119+
* Clears the internal list of pending actions without sending it to the
120+
* associated `stream`.
121+
* @returns {Readline} this
122+
*/
123+
rollback() {
124+
this.#todo = [];
125+
return this;
126+
}
127+
}
128+
129+
module.exports = {
130+
Readline,
131+
};

lib/readline.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const {
3939
moveCursor,
4040
} = require('internal/readline/callbacks');
4141
const emitKeypressEvents = require('internal/readline/emitKeypressEvents');
42+
const promises = require('readline/promises');
4243

4344
const {
4445
AbortError,
@@ -462,5 +463,6 @@ module.exports = {
462463
createInterface,
463464
cursorTo,
464465
emitKeypressEvents,
465-
moveCursor
466+
moveCursor,
467+
promises,
466468
};

lib/readline/promises.js

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict';
2+
3+
const {
4+
Promise,
5+
} = primordials;
6+
7+
const {
8+
Readline,
9+
} = require('internal/readline/promises');
10+
11+
const {
12+
Interface: _Interface,
13+
kQuestionCancel,
14+
} = require('internal/readline/interface');
15+
16+
const {
17+
AbortError,
18+
} = require('internal/errors');
19+
20+
class Interface extends _Interface {
21+
// eslint-disable-next-line no-useless-constructor
22+
constructor(input, output, completer, terminal) {
23+
super(input, output, completer, terminal);
24+
}
25+
question(query, options = {}) {
26+
return new Promise((resolve, reject) => {
27+
if (options.signal) {
28+
if (options.signal.aborted) {
29+
return reject(new AbortError());
30+
}
31+
32+
options.signal.addEventListener('abort', () => {
33+
this[kQuestionCancel]();
34+
reject(new AbortError());
35+
}, { once: true });
36+
}
37+
38+
super.question(query, resolve);
39+
});
40+
}
41+
}
42+
43+
function createInterface(input, output, completer, terminal) {
44+
return new Interface(input, output, completer, terminal);
45+
}
46+
47+
module.exports = {
48+
Interface,
49+
Readline,
50+
createInterface,
51+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Flags: --expose-internals
2+
3+
4+
import '../common/index.mjs';
5+
import assert from 'assert';
6+
import { Readline } from 'readline/promises';
7+
import { Writable } from 'stream';
8+
9+
import utils from 'internal/readline/utils';
10+
const { CSI } = utils;
11+
12+
const INVALID_ARG = {
13+
name: 'TypeError',
14+
code: 'ERR_INVALID_ARG_TYPE',
15+
};
16+
17+
class TestWritable extends Writable {
18+
data = '';
19+
_write(chunk, encoding, callback) {
20+
this.data += chunk.toString();
21+
callback();
22+
}
23+
}
24+
25+
[
26+
undefined, null,
27+
0, 1, 1n, 1.1, NaN, Infinity,
28+
true, false,
29+
Symbol(),
30+
'', '1',
31+
[], {}, () => {},
32+
].forEach((arg) =>
33+
assert.throws(() => new Readline(arg), INVALID_ARG)
34+
);
35+
36+
{
37+
const writable = new TestWritable();
38+
const readline = new Readline(writable);
39+
40+
await readline.clearScreenDown().commit();
41+
assert.deepStrictEqual(writable.data, CSI.kClearScreenDown);
42+
await readline.clearScreenDown().commit();
43+
44+
writable.data = '';
45+
await readline.clearScreenDown().rollback();
46+
assert.deepStrictEqual(writable.data, '');
47+
48+
writable.data = '';
49+
await readline.clearLine(-1).commit();
50+
assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning);
51+
52+
writable.data = '';
53+
await readline.clearLine(1).commit();
54+
assert.deepStrictEqual(writable.data, CSI.kClearToLineEnd);
55+
56+
writable.data = '';
57+
await readline.clearLine(0).commit();
58+
assert.deepStrictEqual(writable.data, CSI.kClearLine);
59+
60+
writable.data = '';
61+
await readline.clearLine(-1).commit();
62+
assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning);
63+
64+
await readline.clearLine(0, null).commit();
65+
66+
// Nothing is written when moveCursor 0, 0
67+
for (const set of
68+
[
69+
[0, 0, ''],
70+
[1, 0, '\x1b[1C'],
71+
[-1, 0, '\x1b[1D'],
72+
[0, 1, '\x1b[1B'],
73+
[0, -1, '\x1b[1A'],
74+
[1, 1, '\x1b[1C\x1b[1B'],
75+
[-1, 1, '\x1b[1D\x1b[1B'],
76+
[-1, -1, '\x1b[1D\x1b[1A'],
77+
[1, -1, '\x1b[1C\x1b[1A'],
78+
]) {
79+
writable.data = '';
80+
await readline.moveCursor(set[0], set[1]).commit();
81+
assert.deepStrictEqual(writable.data, set[2]);
82+
writable.data = '';
83+
await readline.moveCursor(set[0], set[1]).commit();
84+
assert.deepStrictEqual(writable.data, set[2]);
85+
}
86+
87+
88+
await readline.moveCursor(1, 1, null).commit();
89+
90+
writable.data = '';
91+
[
92+
undefined, null,
93+
true, false,
94+
Symbol(),
95+
'', '1',
96+
[], {}, () => {},
97+
].forEach((arg) =>
98+
assert.throws(() => readline.cursorTo(arg), INVALID_ARG)
99+
);
100+
assert.strictEqual(writable.data, '');
101+
102+
writable.data = '';
103+
assert.throws(() => readline.cursorTo('a', 'b'), INVALID_ARG);
104+
assert.strictEqual(writable.data, '');
105+
106+
writable.data = '';
107+
assert.throws(() => readline.cursorTo('a', 1), INVALID_ARG);
108+
assert.strictEqual(writable.data, '');
109+
110+
writable.data = '';
111+
assert.throws(() => readline.cursorTo(1, 'a'), INVALID_ARG);
112+
assert.strictEqual(writable.data, '');
113+
114+
writable.data = '';
115+
await readline.cursorTo(1).commit();
116+
assert.strictEqual(writable.data, '\x1b[2G');
117+
118+
writable.data = '';
119+
await readline.cursorTo(1, 2).commit();
120+
assert.strictEqual(writable.data, '\x1b[3;2H');
121+
122+
writable.data = '';
123+
await readline.cursorTo(1, 2).commit();
124+
assert.strictEqual(writable.data, '\x1b[3;2H');
125+
126+
writable.data = '';
127+
await readline.cursorTo(1).cursorTo(1, 2).commit();
128+
assert.strictEqual(writable.data, '\x1b[2G\x1b[3;2H');
129+
130+
writable.data = '';
131+
await readline.cursorTo(1).commit();
132+
assert.strictEqual(writable.data, '\x1b[2G');
133+
134+
// Verify that cursorTo() rejects if x or y is NaN.
135+
[1.1, NaN, Infinity].forEach((arg) => {
136+
assert.throws(() => readline.cursorTo(arg), {
137+
code: 'ERR_OUT_OF_RANGE',
138+
name: 'RangeError',
139+
});
140+
});
141+
142+
[1.1, NaN, Infinity].forEach((arg) => {
143+
assert.throws(() => readline.cursorTo(1, arg), {
144+
code: 'ERR_OUT_OF_RANGE',
145+
name: 'RangeError',
146+
});
147+
});
148+
149+
assert.throws(() => readline.cursorTo(NaN, NaN), {
150+
code: 'ERR_OUT_OF_RANGE',
151+
name: 'RangeError',
152+
});
153+
}
154+
155+
{
156+
const error = new Error();
157+
const writable = new class extends Writable {
158+
_write() { throw error; }
159+
}();
160+
const readline = new Readline(writable);
161+
162+
await assert.rejects(readline.cursorTo(1).commit(), error);
163+
}

0 commit comments

Comments
 (0)