Skip to content

Commit 5cd1a54

Browse files
authored
Rework internal implementation (#6)
This is intended to be landed as a semver-major change, and drops testing for unsupported Node.js versions (10.x, 13.x). - Remove the legacy, non-class implementation. - Remove usage of explicit ANSI cursors. - This fixes #5 and https://jira.mongodb.org/browse/MONGOSH-734. - Make the monkey-patching of `._insertString()` minimal, in particular, do not rewrite the full line if we do not have to. - This fixes https://jira.mongodb.org/browse/MONGOSH-691. - Refactor `._writeOutput()` so that, instead of working on the assumption that we’re always re-writing the full line, account for the possibility that we need to remove some characters from the output and add them back. - For example, if the input is `a = functio` and the user types `n`, we remove the existing characters for `functio` and instead print the newly highlighted `function` keyword, but we do not go back further and leave the `a = ` part untouched because its highlighting has not changed. Fixes: #5 Fixes: https://jira.mongodb.org/browse/MONGOSH-734 Fixes: https://jira.mongodb.org/browse/MONGOSH-691
1 parent 487e911 commit 5cd1a54

File tree

8 files changed

+1443
-813
lines changed

8 files changed

+1443
-813
lines changed

.github/workflows/nodejs.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
strategy:
1818
matrix:
19-
node-version: [10.x, 12.x, 13.x]
19+
node-version: [12.x, 14.x, 16.x]
2020

2121
steps:
2222
- uses: actions/checkout@v2
@@ -35,7 +35,7 @@ jobs:
3535

3636
strategy:
3737
matrix:
38-
node-version: [10.x, 12.x, 13.x]
38+
node-version: [12.x, 14.x, 16.x]
3939

4040
steps:
4141
- uses: actions/checkout@v2
@@ -54,7 +54,7 @@ jobs:
5454

5555
strategy:
5656
matrix:
57-
node-version: [10.x, 12.x, 13.x]
57+
node-version: [12.x, 14.x, 16.x]
5858

5959
steps:
6060
- uses: actions/checkout@v2
@@ -65,4 +65,4 @@ jobs:
6565
- run: npm i
6666
- run: npm test
6767
env:
68-
CI: true
68+
CI: true

index.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
const semver = require('semver');
1+
'use strict';
22

33
// Functions that return the right implementation
4-
// depending on the node version.
4+
// depending on whether we are using a TTY or not.
55
// They are functions rather than variables because we
66
// only want to execute the require if that's the right version to load.
7-
// This way, we don't monkey-patch the Interface prototype of the readline
8-
// module for nothing.
97
const prettyReplUnsupported = () => require('repl');
10-
const prettyReplNode12 = () => require('./lib/pretty-repl-compat');
118
const prettyRepl = () => require('./lib/pretty-repl');
129

13-
const impl = (tty) => (!tty || semver.lt(process.version, '11.0.0')) ? prettyReplUnsupported()
14-
: semver.lt(process.version, '13.0.0') ? prettyReplNode12() : prettyRepl();
10+
const impl = (tty) => !tty ? prettyReplUnsupported() : prettyRepl();
1511

1612
function isReplTerminal(options) {
1713
if (options.terminal !== undefined)

lib/memoize-string-transformer.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use strict';
2+
// Helper function that converts functions into memoized versions
3+
// of themselves with a fixed-size LRU cache.
4+
module.exports = function memoizeStringTransformerMethod (cacheSize, fn) {
5+
const cache = new Map();
6+
return function (str) {
7+
if (cache.has(str)) {
8+
return cache.get(str);
9+
}
10+
const result = fn.call(this, str);
11+
cache.set(str, result);
12+
if (cache.size > cacheSize) {
13+
const [[oldestKey]] = cache;
14+
cache.delete(oldestKey);
15+
}
16+
return result;
17+
};
18+
};

lib/pretty-repl-compat.js

Lines changed: 0 additions & 76 deletions
This file was deleted.

lib/pretty-repl.js

Lines changed: 150 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,58 @@
1+
'use strict';
12
const repl = require('repl');
23
const highlight = require('./highlight');
3-
const ansi = require('ansi');
4+
const memoizeStringTransformerMethod = require('./memoize-string-transformer');
5+
const ansiRegex = require('ansi-regex');
6+
const stripAnsi = require('strip-ansi');
7+
8+
// Regex that matches all occurrences of ANSI escape sequences in a string.
9+
const ansiRegexMatchAll = ansiRegex();
10+
// Regex that matches ANSI escape sequences only at the beginning of a string.
11+
const ansiRegexMatchBeginningOnly = new RegExp(`^(${ansiRegexMatchAll.source})`);
12+
13+
// Compute the length of the longest common prefix of 'before' and 'after',
14+
// taking ANSI escape sequences into account. For example:
15+
// 'abcd', 'abab' -> 2
16+
// 'ab\x1b[3m', 'ab\x1b[5m' -> 2 (not 4)
17+
function computeCommonPrefixLength(before, after) {
18+
let i = 0;
19+
while (i < Math.min(before.length, after.length) &&
20+
before[i] === after[i]) {
21+
const match = before.substr(i).match(ansiRegexMatchBeginningOnly);
22+
if (match && match.index === 0) {
23+
if (after.indexOf(match[0], i) === i) {
24+
// A matching ANSI escape sequence in both strings, add the length of it
25+
i += match[0].length;
26+
} else {
27+
// Non-matching ANSI escape sequence, treat as entirely different from here
28+
break;
29+
}
30+
} else {
31+
i++;
32+
}
33+
}
34+
return i;
35+
}
36+
37+
// Count the number of Unicode codepoints in a string, i.e. [...str].length
38+
// without the intermediate Array instance.
39+
function characterCount(str) {
40+
let i, c;
41+
for (i = 0, c = 0; i < str.length; i++) {
42+
if (str.charCodeAt(i) >= 0xD800 && str.charCodeAt(i) < 0xDC00) {
43+
continue;
44+
}
45+
c++;
46+
}
47+
return c;
48+
}
449

550
class PrettyREPLServer extends repl.REPLServer {
651
constructor (options = {}) {
752
super(options);
853
options.output = options.output || process.stdout;
954
this.colorize = (options && options.colorize) || highlight(options.output);
10-
this.ansiCursor = ansi(options.output);
55+
this.lineBeforeInsert = undefined;
1156

1257
// For some reason, tests fail if we don't initialize line to be the empty string.
1358
// Specifically, `REPLServer.Interface.getCursorPos()` finds itself in a state where `line`
@@ -17,47 +62,123 @@ class PrettyREPLServer extends repl.REPLServer {
1762
}
1863

1964
_writeToOutput (stringToWrite) {
20-
if (stringToWrite === '\r\n' || stringToWrite === ' ') {
65+
// Skip false-y values, and if we print only whitespace or have not yet
66+
// been fully initialized, just write to output directly.
67+
if (!stringToWrite) return;
68+
if (stringToWrite.match(/^\s+$/) || !this.colorize) {
2169
this.output.write(stringToWrite);
2270
return;
2371
}
24-
if (!stringToWrite) return;
2572

26-
const startsWithPrompt = stringToWrite.indexOf(this._prompt) === 0;
27-
if (startsWithPrompt) {
28-
this.output.write(this._prompt);
29-
stringToWrite = stringToWrite.substring(this._prompt.length);
30-
this.renderCurrentLine(stringToWrite);
73+
// In this case, the method is being called from _insertString, which appends
74+
// a string to the end of the current line.
75+
if (this.lineBeforeInsert !== undefined && this.lineBeforeInsert + stringToWrite === this.line) {
76+
this._writeAppendedString(stringToWrite);
77+
} else if (stringToWrite.startsWith(this._prompt)) {
78+
this._writeFullLine(stringToWrite);
3179
} else {
80+
// In other situations, we don't know what to do and just do whatever
81+
// the Node.js REPL implementation itself does.
3282
super._writeToOutput(stringToWrite);
3383
}
3484
}
3585

36-
_insertString (c) {
37-
if (this.cursor < this.line.length) {
38-
const beg = this.line.slice(0, this.cursor);
39-
const end = this.line.slice(this.cursor, this.line.length);
40-
this.line = beg + c + end;
41-
this.cursor += c.length;
42-
this._refreshLine();
43-
} else {
44-
this.line += c;
45-
this.cursor += c.length;
46-
this._refreshLine();
47-
this._moveCursor(0);
86+
_writeAppendedString(stringToWrite) {
87+
// First, we simplify whatever existing line structure is present in a
88+
// way that preserves highlighting of any subsequent part of the code.
89+
// The goal here is to reduce the amount of code that needs to be highlighted,
90+
// because this typically runs once for each character that is entered.
91+
const simplified = this._stripCompleteJSStructures(this.lineBeforeInsert);
92+
93+
// Colorize the 'before' state.
94+
const before = this._doColorize(simplified);
95+
// Colorize the 'after' state, using the same simplification (this works because
96+
// `lineBeforeInsert + stringToWrite === line` implies that
97+
// `simplified + stringToWrite` is a valid simplification of `line`,
98+
// and the former is a precondition for this method to be called).
99+
const after = this._doColorize(simplified + stringToWrite);
100+
101+
// Find the first character or escape sequence that differs in `before` and `after`.
102+
const commonPrefixLength = computeCommonPrefixLength(before, after);
103+
104+
// Gather all escape sequences that occur in the *common* part of the string.
105+
// Applying them all one after another puts the terminal into the state of the
106+
// highlighting at the divergence point.
107+
// (This makes the assumption that those escape sequences only affect formatting,
108+
// not e.g. cursor position, which seem like a reasonable assumption to make
109+
// for the output from a syntax highlighter).
110+
let ansiStatements = (before.slice(0, commonPrefixLength).match(ansiRegexMatchAll) || []);
111+
112+
// Filter out any foreground color settings before the last reset (\x1b[39m).
113+
// This helps reduce the amount of useless clutter we write a bit, and in
114+
// particular helps the mongosh test suite not ReDOS itself when verifying
115+
// output coloring.
116+
const lastForegroundColorReset = ansiStatements.lastIndexOf('\x1b[39m');
117+
if (lastForegroundColorReset !== -1) {
118+
ansiStatements = ansiStatements.filter((sequence, index) => {
119+
// Keep escape sequences that come after the last full reset or modify
120+
// something other than the foreground color.
121+
return index > lastForegroundColorReset || !sequence.match(/^\x1b\[3\d.*m$/);
122+
});
48123
}
124+
125+
// In order to get from `before` to `after`, we have to reduce the `before` state
126+
// back to the common prefix of the two. Do that by counting all the
127+
// non-escape-sequence characters in what comes after the common prefix
128+
// in `before`.
129+
const backtrackLength = characterCount(stripAnsi(before.slice(commonPrefixLength)));
130+
131+
// Put it all together: Backtrack from `before` to the common prefix, apply
132+
// all the escape sequences that were present before, and then apply the
133+
// new output from `after`.
134+
this.output.write('\b'.repeat(backtrackLength) + ansiStatements.join('') + after.slice(commonPrefixLength));
49135
}
50136

51-
renderCurrentLine (stringToWrite) {
52-
if (!this.ansiCursor) {
53-
return;
137+
_writeFullLine(stringToWrite) {
138+
// If the output starts with the prompt (which is when this method is called),
139+
// it's reasonable to assume that we're printing a full line (which happens
140+
// relatively frequently with the Node.js REPL).
141+
// In those cases, we split the string into prompt and non-prompt parts,
142+
// and colorize the full non-prompt part.
143+
stringToWrite = stringToWrite.substring(this._prompt.length);
144+
this.output.write(this._prompt + this._doColorize(stringToWrite));
145+
}
146+
147+
_insertString (c) {
148+
this.lineBeforeInsert = this.line;
149+
try {
150+
return super._insertString (c);
151+
} finally {
152+
this.lineBeforeInsert = undefined;
54153
}
55-
const promptLength = this._prompt.length;
56-
const cursorPos = this._getCursorPos();
57-
const nX = cursorPos.cols;
58-
this.ansiCursor.horizontalAbsolute(promptLength + 1).eraseLine().write(this.colorize(stringToWrite));
59-
this.ansiCursor.horizontalAbsolute(nX);
60154
}
155+
156+
_doColorize = memoizeStringTransformerMethod(100, function(str) {
157+
return this.colorize(str);
158+
});
159+
160+
_stripCompleteJSStructures(str) {
161+
// Remove substructures of the JS input string `str` in order to simplify it,
162+
// by repeatedly removing matching pairs of quotes and parentheses/brackets.
163+
let before;
164+
do {
165+
before = str;
166+
str = this._stripCompleteJSStructuresStep(before);
167+
} while (before !== str);
168+
return str;
169+
}
170+
171+
_stripCompleteJSStructuresStep = memoizeStringTransformerMethod(10000, function(str) {
172+
// This regular expression matches:
173+
// - matching pairs of (), without any of ()[]{}`'" in between
174+
// - matching pairs of [], without any of ()[]{}`'" in between
175+
// - matching pairs of {}, without any of ()[]{}`'" in between
176+
// - matching pairs of '', with only non-'\, \\, and \' preceded by an even number of \ in between
177+
// - matching pairs of "", with only non-"\, \\, and \" preceded by an even number of \ in between
178+
// - matching pairs of ``, with only non-`{}\, \\ and \` preceded by an even number of \ in between
179+
const re = /\([^\(\)\[\]\{\}`'"]*\)|\[[^\(\)\[\]\{\}`'"]*\]|\{[^\(\)\[\]\{\}`'"]*\}|'([^'\\]|(?<=[^\\](\\\\)*)\\'|\\\\)*'|"([^"\\]|(?<=[^\\](\\\\)*)\\"|\\\\)*"|`([^\{\}`\\]|(?<=[^\\](\\\\)*)\\`|\\\\)*`/g;
180+
return str.replace(re, '');
181+
});
61182
}
62183

63184
module.exports = {

0 commit comments

Comments
 (0)