Skip to content

Commit eeb2dd8

Browse files
mcasimiraherlihy
andauthored
Read input line by line (#206)
* fix: fix multiline input * fix: readd missing enableBlockOnNewLine * fix displayPrompt output * avoid to run bootstrap on each commit * test: add unit tests for line-by-line-input * test: fix arg-parser tests * test: fix some e2e tests * .editor test fix * put checks back Co-authored-by: aherlihy <[email protected]>
1 parent bae7424 commit eeb2dd8

File tree

8 files changed

+456
-101
lines changed

8 files changed

+456
-101
lines changed

packages/cli-repl/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"start-async": "node --experimental-repl-await bin/mongosh.js start --async",
2121
"test": "mocha --timeout 15000 --colors -r ts-node/register \"./{src,test}/**/*.spec.ts\"",
2222
"test-ci": "mocha --timeout 15000 -r ts-node/register \"./{src,test}/**/*.spec.ts\"",
23+
"pretest-e2e": "npm run compile-ts",
24+
"test-e2e": "mocha --timeout 15000 --colors -r ts-node/register \"./test/e2e.spec.ts\"",
2325
"lint": "eslint \"**/*.{js,ts,tsx}\"",
2426
"check": "npm run lint",
2527
"prepublish": "npm run compile-ts"

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import parse, { getLocale } from './arg-parser';
22
import { expect } from 'chai';
3+
import stripAnsi from 'strip-ansi';
34

45
const NODE = 'node';
56
const MONGOSH = 'mongosh';
@@ -237,8 +238,14 @@ describe('arg-parser', () => {
237238
const argv = [ ...baseArgv, uri, '--what' ];
238239

239240
it('raises an error', () => {
240-
expect(parse.bind(null, argv)).to.
241-
throw('Error parsing command line: unrecognized option: --what');
241+
try {
242+
parse(argv);
243+
throw new Error('should have thrown');
244+
} catch (err) {
245+
expect(
246+
stripAnsi(err.message)
247+
).to.contain('Error parsing command line: unrecognized option: --what');
248+
}
242249
});
243250
});
244251
});
@@ -731,8 +738,14 @@ describe('arg-parser', () => {
731738
const argv = [ ...baseArgv, uri, '--what' ];
732739

733740
it('raises an error', () => {
734-
expect(parse.bind(null, argv)).to.
735-
throw('Error parsing command line: unrecognized option: --what');
741+
try {
742+
parse(argv);
743+
throw new Error('should have thrown');
744+
} catch (err) {
745+
expect(
746+
stripAnsi(err.message)
747+
).to.contain('Error parsing command line: unrecognized option: --what');
748+
}
736749
});
737750
});
738751
});

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import read from 'read';
2424
import os from 'os';
2525
import fs from 'fs';
2626
import { redactPwd } from '.';
27+
import { LineByLineInput } from './line-by-line-input';
2728

2829
/**
2930
* Connecting text key.
@@ -44,13 +45,15 @@ class CliRepl {
4445
private userId: ObjectId;
4546
private options: CliOptions;
4647
private mongoshDir: string;
48+
private lineByLineInput: LineByLineInput;
4749

4850
/**
4951
* Instantiate the new CLI Repl.
5052
*/
5153
constructor(driverUri: string, driverOptions: NodeOptions, options: CliOptions) {
5254
this.options = options;
5355
this.mongoshDir = path.join(os.homedir(), '.mongodb/mongosh/');
56+
this.lineByLineInput = new LineByLineInput(process.stdin);
5457

5558
this.createMongoshDir();
5659

@@ -105,11 +108,28 @@ class CliRepl {
105108
const version = this.buildInfo.version;
106109

107110
this.repl = repl.start({
111+
input: this.lineByLineInput,
112+
output: process.stdout,
108113
prompt: '> ',
109114
writer: this.writer,
110115
completer: completer.bind(null, version),
116+
terminal: true
111117
});
112118

119+
const originalDisplayPrompt = this.repl.displayPrompt.bind(this.repl);
120+
121+
this.repl.displayPrompt = (...args: any[]): any => {
122+
originalDisplayPrompt(...args);
123+
this.lineByLineInput.nextLine();
124+
};
125+
126+
const originalEditorAction = this.repl.commands.editor.action.bind(this.repl);
127+
128+
this.repl.commands.editor.action = (): any => {
129+
this.lineByLineInput.disableBlockOnNewline();
130+
return originalEditorAction();
131+
};
132+
113133
this.repl.defineCommand('clear', {
114134
help: '',
115135
action: () => {
@@ -120,6 +140,8 @@ class CliRepl {
120140
const originalEval = util.promisify(this.repl.eval);
121141

122142
const customEval = async(input, context, filename, callback): Promise<any> => {
143+
this.lineByLineInput.enableBlockOnNewLine();
144+
123145
let result;
124146

125147
try {
@@ -128,7 +150,7 @@ class CliRepl {
128150
if (isRecoverableError(input)) {
129151
return callback(new Recoverable(err));
130152
}
131-
result = err;
153+
return callback(err);
132154
}
133155
callback(null, result);
134156
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from 'chai';
2+
import { StringDecoder } from 'string_decoder';
3+
import { EventEmitter } from 'events';
4+
import { LineByLineInput } from './line-by-line-input';
5+
6+
describe('LineByLineInput', () => {
7+
let stdinMock: NodeJS.ReadStream;
8+
let decoder: StringDecoder;
9+
let forwardedChunks: string[];
10+
let lineByLineInput: LineByLineInput;
11+
12+
beforeEach(() => {
13+
stdinMock = new EventEmitter() as NodeJS.ReadStream;
14+
stdinMock.isPaused = (): boolean => false;
15+
decoder = new StringDecoder();
16+
forwardedChunks = [];
17+
lineByLineInput = new LineByLineInput(stdinMock);
18+
lineByLineInput.on('data', (chunk) => {
19+
const decoded = decoder.write(chunk);
20+
if (decoded) {
21+
forwardedChunks.push(decoded);
22+
}
23+
});
24+
});
25+
26+
context('when block on newline is enabled (default)', () => {
27+
it('does not forward characters after newline', () => {
28+
stdinMock.emit('data', Buffer.from('ab\nc'));
29+
expect(forwardedChunks).to.deep.equal(['a', 'b', '\n']);
30+
});
31+
32+
it('forwards CTRL-C anyway and as soon as is received', () => {
33+
stdinMock.emit('data', Buffer.from('\n\u0003'));
34+
expect(forwardedChunks).to.contain('\u0003');
35+
});
36+
37+
it('forwards CTRL-D anyway and as soon as is received', () => {
38+
stdinMock.emit('data', Buffer.from('\n\u0004'));
39+
expect(forwardedChunks).to.contain('\u0004');
40+
});
41+
42+
it('unblocks on nextline', () => {
43+
stdinMock.emit('data', Buffer.from('ab\nc'));
44+
lineByLineInput.nextLine();
45+
expect(forwardedChunks).to.deep.equal(['a', 'b', '\n', 'c']);
46+
});
47+
});
48+
49+
context('when block on newline is disabled', () => {
50+
it('does forwards all the characters', () => {
51+
lineByLineInput.disableBlockOnNewline();
52+
stdinMock.emit('data', Buffer.from('ab\nc'));
53+
expect(forwardedChunks).to.deep.equal(['ab\nc']);
54+
});
55+
});
56+
57+
context('when block on newline is disabled and re-enabled', () => {
58+
it('does forwards all the characters', () => {
59+
lineByLineInput.disableBlockOnNewline();
60+
lineByLineInput.enableBlockOnNewLine();
61+
stdinMock.emit('data', Buffer.from('ab\nc'));
62+
expect(forwardedChunks).to.deep.equal(['a', 'b', '\n']);
63+
});
64+
});
65+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { EventEmitter } from 'events';
2+
import { StringDecoder } from 'string_decoder';
3+
4+
const LINE_ENDING_RE = /\r?\n|\r(?!\n)/;
5+
const CTRL_C = '\u0003';
6+
const CTRL_D = '\u0004';
7+
8+
/**
9+
* A proxy for `tty.ReadStream` that allows to read
10+
* the stream line by line.
11+
*
12+
* Each time a newline is encountered the stream wont emit further data
13+
* untill `.nextLine()` is called.
14+
*
15+
* NOTE: the control sequences Ctrl+C and Ctrl+D are not buffered and instead
16+
* are forwarded regardless.
17+
*
18+
* Is possible to disable the "line splitting" by calling `.disableBlockOnNewline()` and
19+
* re-enable it by calling `.enableBlockOnNewLine()`.
20+
*
21+
* If the line splitting is disabled the stream will behave like
22+
* the proxied `tty.ReadStream`, forwarding all the characters.
23+
*/
24+
export class LineByLineInput {
25+
private _emitter: EventEmitter;
26+
private _originalInput: NodeJS.ReadStream;
27+
private _forwarding: boolean;
28+
private _blockOnNewLineEnabled: boolean;
29+
private _charQueue: string[];
30+
private _decoder: StringDecoder;
31+
32+
constructor(readable: NodeJS.ReadStream) {
33+
this._emitter = new EventEmitter();
34+
this._originalInput = readable;
35+
this._forwarding = true;
36+
this._blockOnNewLineEnabled = true;
37+
this._charQueue = [];
38+
this._decoder = new StringDecoder('utf-8');
39+
40+
readable.on('data', this._onData);
41+
42+
const proxy = new Proxy(readable, {
43+
get: (target: NodeJS.ReadStream, property: string): any => {
44+
if (typeof property === 'string' &&
45+
!property.startsWith('_') &&
46+
typeof this[property] === 'function'
47+
) {
48+
return this[property].bind(this);
49+
}
50+
51+
return target[property];
52+
}
53+
});
54+
55+
return (proxy as unknown) as LineByLineInput;
56+
}
57+
58+
on(event: string, handler: (...args: any[]) => void): void {
59+
if (event === 'data') {
60+
this._emitter.on('data', handler);
61+
// we may have buffered data for the first listener
62+
this._flush();
63+
return;
64+
}
65+
66+
this._originalInput.on(event, handler);
67+
return;
68+
}
69+
70+
nextLine(): void {
71+
this._resumeForwarding();
72+
this._flush();
73+
}
74+
75+
enableBlockOnNewLine(): void {
76+
this._blockOnNewLineEnabled = true;
77+
}
78+
79+
disableBlockOnNewline(): void {
80+
this._blockOnNewLineEnabled = false;
81+
this._flush();
82+
}
83+
84+
private _onData = (chunk: Buffer): void => {
85+
if (this._blockOnNewLineEnabled) {
86+
return this._forwardAndBlockOnNewline(chunk);
87+
}
88+
89+
return this._forwardWithoutBlocking(chunk);
90+
};
91+
92+
private _forwardAndBlockOnNewline(chunk: Buffer): void {
93+
const chars = this._decoder.write(chunk);
94+
for (const char of chars) {
95+
if (this._isCtrlC(char) || this._isCtrlD(char)) {
96+
this._emitChar(char);
97+
} else {
98+
this._charQueue.push(char);
99+
}
100+
}
101+
this._flush();
102+
}
103+
104+
private _forwardWithoutBlocking(chunk: Buffer): void {
105+
// keeps decoding state consistent
106+
this._decoder.write(chunk);
107+
this._emitChunk(chunk);
108+
}
109+
110+
private _pauseForwarding(): void {
111+
this._forwarding = false;
112+
}
113+
114+
private _resumeForwarding(): void {
115+
this._forwarding = true;
116+
}
117+
118+
private _shouldForward(): boolean {
119+
// If we are not blocking on new lines
120+
// we just forward everything as is,
121+
// otherwise we forward only if the forwarding
122+
// is not paused.
123+
124+
return !this._blockOnNewLineEnabled || this._forwarding;
125+
}
126+
127+
private _emitChar(char): void {
128+
this._emitChunk(Buffer.from(char, 'utf8'));
129+
}
130+
131+
private _emitChunk(chunk: Buffer): void {
132+
this._emitter.emit('data', chunk);
133+
}
134+
135+
private _flush(): void {
136+
// there is nobody to flush for
137+
if (this._emitter.listenerCount('data') === 0) {
138+
return;
139+
}
140+
141+
while (
142+
this._charQueue.length &&
143+
this._shouldForward() &&
144+
145+
// We don't forward residual characters we could
146+
// have in the buffer if in the meanwhile something
147+
// downstream explicitly called pause(), as that may cause
148+
// unexpected behaviors.
149+
!this._originalInput.isPaused()
150+
) {
151+
const char = this._charQueue.shift();
152+
153+
if (this._isLineEnding(char)) {
154+
this._pauseForwarding();
155+
}
156+
157+
this._emitChar(char);
158+
}
159+
}
160+
161+
private _isLineEnding(char: string): boolean {
162+
return LINE_ENDING_RE.test(char);
163+
}
164+
165+
private _isCtrlD(char: string): boolean {
166+
return char === CTRL_D;
167+
}
168+
169+
private _isCtrlC(char: string): boolean {
170+
return char === CTRL_C;
171+
}
172+
}

0 commit comments

Comments
 (0)