Skip to content

Commit 4232a98

Browse files
authored
feat(cli-repl): add support for bracketed paste in REPL MONGOSH-1909 (#2328)
Bracketed paste allows us to receive a copy-pasted piece of mongosh as a single block, rather than interpreting it line-by-line. For now, this requires some monkey-patching of Node.js internals, so a follow-up ticket will include work to upstream support for this into Node.js core.
1 parent 03a4dfc commit 4232a98

File tree

12 files changed

+272
-16
lines changed

12 files changed

+272
-16
lines changed

.evergreen/setup-env.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export MONGOSH_TEST_ONLY_MAX_LOG_FILE_COUNT=100000
1717
export IS_MONGOSH_EVERGREEN_CI=1
1818
export DEBUG="mongodb*,$DEBUG"
1919

20+
# This is, weirdly enough, specifically set on s390x hosts, but messes
21+
# with our e2e tests.
22+
if [ x"$TERM" = x"dumb" ]; then
23+
unset TERM
24+
fi
25+
echo "TERM variable is set to '${TERM:-}'"
26+
2027
if [ "$OS" != "Windows_NT" ]; then
2128
if which realpath; then # No realpath on macOS, but also not needed there
2229
export HOME="$(realpath "$HOME")" # Needed to de-confuse nvm when /home is a symlink

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli-repl/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"access": "public"
4444
},
4545
"engines": {
46-
"node": ">=16.15.0"
46+
"node": ">=18.19.0"
4747
},
4848
"mongosh": {
4949
"ciRequiredOptionalDependencies": {

packages/cli-repl/src/async-repl.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,43 @@ describe('AsyncRepl', function () {
313313
});
314314
});
315315
});
316+
317+
it('does not run pasted text immediately', async function () {
318+
const { input, output } = createDefaultAsyncRepl({
319+
terminal: true,
320+
useColors: false,
321+
});
322+
323+
output.read(); // Read prompt so it doesn't mess with further output
324+
input.write('\x1b[200~1234\n*5678\n\x1b[201~');
325+
await tick();
326+
// ESC[nG is horizontal cursor movement, ESC[nJ is cursor display reset
327+
expect(output.read()).to.equal(
328+
'1234\r\n\x1B[1G\x1B[0J... \x1B[5G*5678\r\n\x1B[1G\x1B[0J... \x1B[5G'
329+
);
330+
input.write('\n');
331+
await tick();
332+
// Contains the expected result after hitting newline
333+
expect(output.read()).to.equal('\r\n7006652\n\x1B[1G\x1B[0J> \x1B[3G');
334+
});
335+
336+
it('allows using ctrl+c to avoid running pasted text', async function () {
337+
const { input, output } = createDefaultAsyncRepl({
338+
terminal: true,
339+
useColors: false,
340+
});
341+
342+
output.read(); // Read prompt so it doesn't mess with further output
343+
input.write('\x1b[200~1234\n*5678\n\x1b[201~');
344+
await tick();
345+
expect(output.read()).to.equal(
346+
'1234\r\n\x1B[1G\x1B[0J... \x1B[5G*5678\r\n\x1B[1G\x1B[0J... \x1B[5G'
347+
);
348+
input.write('\x03'); // Ctrl+C
349+
await tick();
350+
expect(output.read()).to.equal('\r\n\x1b[1G\x1b[0J> \x1b[3G');
351+
input.write('"foo";\n'); // Write something else
352+
await tick();
353+
expect(output.read()).to.equal(`"foo";\r\n'foo'\n\x1B[1G\x1B[0J> \x1B[3G`);
354+
});
316355
});

packages/cli-repl/src/async-repl.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ReadLineOptions } from 'readline';
55
import type { ReplOptions, REPLServer } from 'repl';
66
import type { start as originalStart } from 'repl';
77
import { promisify } from 'util';
8+
import type { KeypressKey } from './repl-paste-support';
89

910
// Utility, inverse of Readonly<T>
1011
type Mutable<T> = {
@@ -75,7 +76,9 @@ function getPrompt(repl: any): string {
7576
export function start(opts: AsyncREPLOptions): REPLServer {
7677
// 'repl' is not supported in startup snapshots yet.
7778
// eslint-disable-next-line @typescript-eslint/no-var-requires
78-
const { Recoverable, start: originalStart } = require('repl');
79+
const { Recoverable, start: originalStart } =
80+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
81+
require('repl') as typeof import('repl');
7982
const { asyncEval, wrapCallbackError = (err) => err, onAsyncSigint } = opts;
8083
if (onAsyncSigint) {
8184
(opts as ReplOptions).breakEvalOnSigint = true;
@@ -96,12 +99,28 @@ export function start(opts: AsyncREPLOptions): REPLServer {
9699
return wasInRawMode;
97100
};
98101

102+
// TODO(MONGOSH-1911): Upstream this feature into Node.js core.
103+
let isPasting = false;
104+
repl.input.on('keypress', (s: string, key: KeypressKey) => {
105+
if (key.name === 'paste-start') {
106+
isPasting = true;
107+
} else if (key.name === 'paste-end') {
108+
isPasting = false;
109+
}
110+
});
111+
99112
(repl as Mutable<typeof repl>).eval = (
100113
input: string,
101114
context: any,
102115
filename: string,
103116
callback: (err: Error | null, result?: any) => void
104117
): void => {
118+
if (isPasting) {
119+
return callback(
120+
new Recoverable(new Error('recoverable because pasting in progress'))
121+
);
122+
}
123+
105124
async function _eval() {
106125
let previouslyInRawMode;
107126

packages/cli-repl/src/cli-repl.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,8 +2612,7 @@ describe('CliRepl', function () {
26122612
for (const { version, deprecated } of [
26132613
{ version: 'v20.5.1', deprecated: false },
26142614
{ version: '20.0.0', deprecated: false },
2615-
{ version: '18.0.0', deprecated: true },
2616-
{ version: '16.20.3', deprecated: true },
2615+
{ version: '18.19.0', deprecated: true },
26172616
]) {
26182617
delete (process as any).version;
26192618
(process as any).version = version;
@@ -2639,7 +2638,7 @@ describe('CliRepl', function () {
26392638

26402639
it('does not print any deprecation warning when CLI is ran with --quiet flag', async function () {
26412640
// Setting all the possible situation for a deprecation warning
2642-
process.version = '16.20.3';
2641+
process.version = '18.20.0';
26432642
process.versions.openssl = '1.1.11';
26442643
cliRepl.getGlibcVersion = () => '1.27';
26452644

packages/cli-repl/src/line-by-line-input.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Readable } from 'stream';
22
import { StringDecoder } from 'string_decoder';
3+
import type { ReadStream } from 'tty';
34

45
const LINE_ENDING_RE = /\r?\n|\r(?!\n)/;
56
const CTRL_C = '\u0003';
@@ -22,14 +23,14 @@ const CTRL_D = '\u0004';
2223
* the proxied `tty.ReadStream`, forwarding all the characters.
2324
*/
2425
export class LineByLineInput extends Readable {
25-
private _originalInput: NodeJS.ReadStream;
26+
private _originalInput: Readable & Partial<ReadStream>;
2627
private _forwarding: boolean;
2728
private _blockOnNewLineEnabled: boolean;
2829
private _charQueue: (string | null)[];
2930
private _decoder: StringDecoder;
3031
private _insidePushCalls: number;
3132

32-
constructor(readable: NodeJS.ReadStream) {
33+
constructor(readable: Readable & Partial<ReadStream>) {
3334
super();
3435
this._originalInput = readable;
3536
this._forwarding = true;
@@ -64,7 +65,7 @@ export class LineByLineInput extends Readable {
6465
);
6566

6667
const proxy = new Proxy(readable, {
67-
get: (target: NodeJS.ReadStream, property: string): any => {
68+
get: (target: typeof readable, property: string): any => {
6869
if (
6970
typeof property === 'string' &&
7071
!property.startsWith('_') &&

packages/cli-repl/src/mongosh-repl.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type { FormatOptions } from './format-output';
4646
import { markTime } from './startup-timing';
4747
import type { Context } from 'vm';
4848
import { Script, createContext, runInContext } from 'vm';
49+
import { installPasteSupport } from './repl-paste-support';
4950

5051
declare const __non_webpack_require__: any;
5152

@@ -135,6 +136,7 @@ class MongoshNodeRepl implements EvaluationListener {
135136
input: Readable;
136137
lineByLineInput: LineByLineInput;
137138
output: Writable;
139+
outputFinishString = ''; // Can add ANSI escape codes to reset state from previously written ones
138140
bus: MongoshBus;
139141
nodeReplOptions: Partial<ReplOptions>;
140142
shellCliOptions: Partial<MongoshCliOptions>;
@@ -251,7 +253,7 @@ class MongoshNodeRepl implements EvaluationListener {
251253
// 'repl' is not supported in startup snapshots yet.
252254
// eslint-disable-next-line @typescript-eslint/no-var-requires
253255
start: require('pretty-repl').start,
254-
input: this.lineByLineInput as unknown as Readable,
256+
input: this.lineByLineInput,
255257
output: this.output,
256258
prompt: '',
257259
writer: this.writer.bind(this),
@@ -387,6 +389,8 @@ class MongoshNodeRepl implements EvaluationListener {
387389
const { repl, instanceState } = this.runtimeState();
388390
if (!repl) return;
389391

392+
this.outputFinishString += installPasteSupport(repl);
393+
390394
const origReplCompleter = promisify(repl.completer.bind(repl)); // repl.completer is callback-style
391395
const mongoshCompleter = completer.bind(
392396
null,
@@ -1079,7 +1083,9 @@ class MongoshNodeRepl implements EvaluationListener {
10791083
await once(rs.repl, 'exit');
10801084
}
10811085
await rs.instanceState.close(true);
1082-
await new Promise((resolve) => this.output.write('', resolve));
1086+
await new Promise((resolve) =>
1087+
this.output.write(this.outputFinishString, resolve)
1088+
);
10831089
}
10841090
}
10851091

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ReplOptions, REPLServer } from 'repl';
2+
import { start } from 'repl';
3+
import type { Readable, Writable } from 'stream';
4+
import { PassThrough } from 'stream';
5+
import { tick } from '../test/repl-helpers';
6+
import { installPasteSupport } from './repl-paste-support';
7+
import { expect } from 'chai';
8+
9+
function createTerminalRepl(extraOpts: Partial<ReplOptions> = {}): {
10+
input: Writable;
11+
output: Readable;
12+
repl: REPLServer;
13+
} {
14+
const input = new PassThrough();
15+
const output = new PassThrough({ encoding: 'utf8' });
16+
17+
const repl = start({
18+
input: input,
19+
output: output,
20+
prompt: '> ',
21+
terminal: true,
22+
useColors: false,
23+
...extraOpts,
24+
});
25+
return { input, output, repl };
26+
}
27+
28+
describe('installPasteSupport', function () {
29+
it('does nothing for non-terminal REPL instances', async function () {
30+
const { repl, output } = createTerminalRepl({ terminal: false });
31+
const onFinish = installPasteSupport(repl);
32+
await tick();
33+
expect(output.read()).to.equal('> ');
34+
expect(onFinish).to.equal('');
35+
});
36+
37+
it('prints a control character sequence that indicates support for bracketed paste', async function () {
38+
const { repl, output } = createTerminalRepl();
39+
const onFinish = installPasteSupport(repl);
40+
await tick();
41+
expect(output.read()).to.include('\x1B[?2004h');
42+
expect(onFinish).to.include('\x1B[?2004l');
43+
});
44+
45+
it('echoes back control characters in the input by default', async function () {
46+
const { repl, input, output } = createTerminalRepl();
47+
installPasteSupport(repl);
48+
await tick();
49+
output.read(); // Ignore prompt etc.
50+
input.write('foo\x1b[Dbar'); // ESC[D = 1 character to the left
51+
await tick();
52+
expect(output.read()).to.equal(
53+
'foo\x1B[1D\x1B[1G\x1B[0J> fobo\x1B[6G\x1B[1G\x1B[0J> fobao\x1B[7G\x1B[1G\x1B[0J> fobaro\x1B[8G'
54+
);
55+
});
56+
57+
it('ignores control characters in the input while pasting', async function () {
58+
const { repl, input, output } = createTerminalRepl();
59+
installPasteSupport(repl);
60+
await tick();
61+
output.read(); // Ignore prompt etc.
62+
input.write('\x1b[200~foo\x1b[Dbar\x1b[201~'); // ESC[D = 1 character to the left
63+
await tick();
64+
expect(output.read()).to.equal('foobar');
65+
});
66+
67+
it('resets to accepting control characters in the input after pasting', async function () {
68+
const { repl, input, output } = createTerminalRepl();
69+
installPasteSupport(repl);
70+
await tick();
71+
output.read();
72+
input.write('\x1b[200~foo\x1b[Dbar\x1b[201~'); // ESC[D = 1 character to the left
73+
await tick();
74+
output.read();
75+
input.write('foo\x1b[Dbar');
76+
await tick();
77+
expect(output.read()).to.equal(
78+
'foo\x1B[1D\x1B[1G\x1B[0J> foobarfobo\x1B[12G\x1B[1G\x1B[0J> foobarfobao\x1B[13G\x1B[1G\x1B[0J> foobarfobaro\x1B[14G'
79+
);
80+
});
81+
82+
it('allows a few special characters while pasting', async function () {
83+
const { repl, input, output } = createTerminalRepl();
84+
installPasteSupport(repl);
85+
await tick();
86+
output.read();
87+
input.write('\x1b[200~12*34\n_*_\n\x1b[201~');
88+
await tick();
89+
expect(output.read()).to.include((12 * 34) ** 2);
90+
});
91+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { REPLServer } from 'repl';
2+
3+
// https://github.com/nodejs/node/blob/d9786109b2a0982677135f0c146f6b591a0e4961/lib/internal/readline/utils.js#L90
4+
// https://nodejs.org/api/readline.html#readlineemitkeypresseventsstream-interface
5+
export type KeypressKey = {
6+
sequence: string | null;
7+
name: string | undefined;
8+
ctrl: boolean;
9+
meta: boolean;
10+
shift: boolean;
11+
code?: string;
12+
};
13+
14+
function* prototypeChain(obj: unknown): Iterable<unknown> {
15+
if (!obj) return;
16+
yield obj;
17+
yield* prototypeChain(Object.getPrototypeOf(obj));
18+
}
19+
20+
export function installPasteSupport(repl: REPLServer): string {
21+
if (!repl.terminal || process.env.TERM === 'dumb') return ''; // No paste needed in non-terminal environments
22+
23+
// TODO(MONGOSH-1911): Upstream as much of this into Node.js core as possible,
24+
// both because of the value to the wider community but also because this is
25+
// messing with Node.js REPL internals to a very unfortunate degree.
26+
repl.output.write('\x1b[?2004h'); // Indicate support for paste mode
27+
const onEnd = '\x1b[?2004l'; // End of support for paste mode
28+
// Find the symbol used for the (internal) _ttyWrite method of readline.Interface
29+
// https://github.com/nodejs/node/blob/d9786109b2a0982677135f0c146f6b591a0e4961/lib/internal/readline/interface.js#L1056
30+
const ttyWriteKey = [...prototypeChain(repl)]
31+
.flatMap((proto) => Object.getOwnPropertySymbols(proto))
32+
.find((s) => String(s).includes('(_ttyWrite)'));
33+
if (!ttyWriteKey)
34+
throw new Error('Could not find _ttyWrite key on readline instance');
35+
repl.input.on('keypress', (s: string, key: KeypressKey) => {
36+
if (key.name === 'paste-start') {
37+
if (Object.prototype.hasOwnProperty.call(repl, ttyWriteKey))
38+
throw new Error(
39+
'Unexpected existing own _ttyWrite key on readline instance'
40+
);
41+
const origTtyWrite = (repl as any)[ttyWriteKey];
42+
Object.defineProperty(repl as any, ttyWriteKey, {
43+
value: function (s: string, key: KeypressKey) {
44+
if (key.ctrl || key.meta || key.code) {
45+
// Special character or escape code sequence, ignore while pasting
46+
return;
47+
}
48+
if (
49+
key.name &&
50+
key.name !== key.sequence?.toLowerCase() &&
51+
!['tab', 'return', 'enter', 'space'].includes(key.name)
52+
) {
53+
// Special character or escape code sequence, ignore while pasting
54+
return;
55+
}
56+
return origTtyWrite.call(this, s, key);
57+
},
58+
enumerable: false,
59+
writable: true,
60+
configurable: true,
61+
});
62+
} else if (key.name === 'paste-end') {
63+
delete (repl as any)[ttyWriteKey];
64+
}
65+
});
66+
return onEnd;
67+
}

0 commit comments

Comments
 (0)