Skip to content

Commit 1340daf

Browse files
authored
feat: add support for bracket matching/colorization MONGOSH-791 (#10)
1 parent 982f241 commit 1340daf

File tree

7 files changed

+289
-2396
lines changed

7 files changed

+289
-2396
lines changed

README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,8 @@ Additionally, it's possible to pass an additional `colorize` property to the opt
3939
}
4040
```
4141

42-
## Known issues
43-
44-
* The implementation in Node.js versions 11 and 12, this module works by monkey-patching the Interface prototype (`readline` module).
45-
If you use `readline` (or a module that depends on it) somewhere else, you may want to test everything thoroughly. In theory, there should be no side effects.
46-
* For Node.js versions older than 11, this module does nothing.
42+
In order to highlighting matching pairs of brackets, a `colorizeMatchingBracket`
43+
is also available.
4744

4845
## Credits
4946

lib/find-all-matching-brackets.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
module.exports = function(str, ignoreMismatches) {
3+
// Find all matching brackets inside a string.
4+
// 'stack' maintains a list of all currently open brackets, 'matches' a list
5+
// of all closed brackets (i.e. the return value).
6+
// If `ignoreMismatches` is true, then e.g. {(x} will be ignored entirely.
7+
// If not, then { and } will be matched and the ( discarded.
8+
const stack = [];
9+
const matches = [];
10+
for (let i = 0; i < str.length; i++) {
11+
const current = stack.length > 0 ? stack[stack.length - 1] : null;
12+
const currentKind = current ? current.kind : '';
13+
switch (currentKind) {
14+
case '':
15+
case '(':
16+
case '[':
17+
case '{':
18+
case '$':
19+
switch (str[i]) {
20+
case '(':
21+
case '[':
22+
case '{':
23+
case "'":
24+
case '"':
25+
case '`':
26+
stack.push({
27+
start: i,
28+
end: -1,
29+
kind: str[i],
30+
parent: current
31+
});
32+
break;
33+
case ')':
34+
case ']':
35+
case '}':
36+
for (let j = stack.length - 1; j >= 0; j--) {
37+
const entry = stack[j];
38+
if ((entry.kind === '(' && str[i] === ')') ||
39+
(entry.kind === '[' && str[i] === ']') ||
40+
(entry.kind === '{' && str[i] === '}') ||
41+
(entry.kind === '$' && str[i] === '}')) {
42+
const isProperMatch = j === stack.length - 1;
43+
stack.splice(j); // Unwind the stack in any case.
44+
if (!ignoreMismatches || isProperMatch) {
45+
entry.end = i;
46+
matches.push(entry);
47+
}
48+
break;
49+
}
50+
}
51+
break;
52+
}
53+
break;
54+
case "'":
55+
case '"':
56+
case '`':
57+
switch (str[i]) {
58+
case "'":
59+
case '"':
60+
case '`':
61+
case '$': {
62+
let j; // Count number of preceding \ characters
63+
for (j = 0; j < i && str[i - j - 1] == '\\'; j++);
64+
if (j % 2 === 1) {
65+
break; // This is an escaped character, so we can ignore it.
66+
}
67+
if ((currentKind === "'" && str[i] === "'") ||
68+
(currentKind === '"' && str[i] === '"') ||
69+
(currentKind === '`' && str[i] === '`')) {
70+
const entry = stack.pop();
71+
entry.end = i;
72+
matches.push(entry);
73+
} else if (currentKind === '`' && str[i] === '$' && str[i+1] === '{') {
74+
stack.push({
75+
start: i++,
76+
end: -1,
77+
kind: '$',
78+
parent: current
79+
});
80+
}
81+
break;
82+
}
83+
}
84+
break;
85+
}
86+
}
87+
return matches;
88+
};

lib/highlight.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ module.exports = (stream) => {
4747
if (stream.getColorDepth() >= 8) level = 2;
4848
if (stream.getColorDepth() >= 24) level = 3;
4949
}
50-
const colorSheet = sheet(new chalk.Instance({ level }));
51-
return (s) => emphasize.highlight('js', s, colorSheet).value;
50+
const chalkInstance = new chalk.Instance({ level });
51+
const colorSheet = sheet(chalkInstance);
52+
const highlight = (s) => emphasize.highlight('js', s, colorSheet).value;
53+
highlight.colorizeMatchingBracket = (s) => chalkInstance.bgBlue(s);
54+
return highlight;
5255
};

lib/pretty-repl.js

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const repl = require('repl');
33
const highlight = require('./highlight');
44
const memoizeStringTransformerMethod = require('./memoize-string-transformer');
5+
const findAllMatchingBrackets = require('./find-all-matching-brackets');
56
const ansiRegex = require('ansi-regex');
67
const stripAnsi = require('strip-ansi');
78

@@ -10,6 +11,10 @@ const ansiRegexMatchAll = ansiRegex();
1011
// Regex that matches ANSI escape sequences only at the beginning of a string.
1112
const ansiRegexMatchBeginningOnly = new RegExp(`^(${ansiRegexMatchAll.source})`);
1213

14+
// Every open/close pair that should be matched against its counterpart for
15+
// highlighting.
16+
const allBrackets = '()[]{}\'"`$';
17+
1318
// Compute the length of the longest common prefix of 'before' and 'after',
1419
// taking ANSI escape sequences into account. For example:
1520
// 'abcd', 'abab' -> 2
@@ -52,7 +57,10 @@ class PrettyREPLServer extends repl.REPLServer {
5257
super(options);
5358
options.output = options.output || process.stdout;
5459
this.colorize = (options && options.colorize) || highlight(options.output);
60+
this.colorizeMatchingBracket = (options && options.colorizeMatchingBracket) ||
61+
highlight(options.output).colorizeMatchingBracket;
5562
this.lineBeforeInsert = undefined;
63+
this.highlightBracketPosition = -1;
5664

5765
// For some reason, tests fail if we don't initialize line to be the empty string.
5866
// Specifically, `REPLServer.Interface.getCursorPos()` finds itself in a state where `line`
@@ -61,6 +69,30 @@ class PrettyREPLServer extends repl.REPLServer {
6169
this.__prettyModuleLoaded = __filename;
6270
}
6371

72+
// If the cursor is moved onto or off a bracket, refresh the whole line so that
73+
// we can mark the matching bracket.
74+
_moveCursor (dx) {
75+
const cursorWasOnBracket = allBrackets.includes(this.line[this.cursor]);
76+
super._moveCursor(dx);
77+
const cursorIsOnBracket = allBrackets.includes(this.line[this.cursor]);
78+
if (cursorWasOnBracket || cursorIsOnBracket) {
79+
this._refreshLine();
80+
}
81+
}
82+
83+
// When refreshinng the whole line, find matching brackets and keep the position
84+
// of the matching one in mind (if there is any).
85+
_refreshLine () {
86+
try {
87+
if (this.colorizeMatchingBracket && allBrackets.includes(this.line[this.cursor])) {
88+
this.highlightBracketPosition = this._findMatchingBracket(this.line, this.cursor);
89+
}
90+
return super._refreshLine();
91+
} finally {
92+
this.highlightBracketPosition = -1;
93+
}
94+
}
95+
6496
_writeToOutput (stringToWrite) {
6597
// Skip false-y values, and if we print only whitespace or have not yet
6698
// been fully initialized, just write to output directly.
@@ -141,7 +173,22 @@ class PrettyREPLServer extends repl.REPLServer {
141173
// In those cases, we split the string into prompt and non-prompt parts,
142174
// and colorize the full non-prompt part.
143175
stringToWrite = stringToWrite.substring(this._prompt.length);
144-
this.output.write(this._prompt + this._doColorize(stringToWrite));
176+
if (this.highlightBracketPosition !== -1) {
177+
// If there is a matching bracket, we mark it in the string before
178+
// highlighting using BOM characters (because it seems safe to assume
179+
// that they are ignored by highlighting) so that we can remember where
180+
// the bracket was.
181+
stringToWrite =
182+
stringToWrite.substring(0, this.highlightBracketPosition) +
183+
'\ufeff' + stringToWrite[this.highlightBracketPosition] + '\ufeff' +
184+
stringToWrite.substring(this.highlightBracketPosition + 1);
185+
stringToWrite = this._doColorize(stringToWrite);
186+
// Then remove the BOM characters again and colorize the bracket in between.
187+
stringToWrite = stringToWrite.replace(/\ufeff(.+)\ufeff/, (_, bracket) => this.colorizeMatchingBracket(bracket));
188+
} else {
189+
stringToWrite = this._doColorize(stringToWrite);
190+
}
191+
this.output.write(this._prompt + stringToWrite);
145192
}
146193

147194
_insertString (c) {
@@ -157,47 +204,45 @@ class PrettyREPLServer extends repl.REPLServer {
157204
return this.colorize(str);
158205
});
159206

160-
_stripCompleteJSStructures(str) {
207+
_stripCompleteJSStructures = memoizeStringTransformerMethod(1000, function(str) {
161208
// 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-
}
209+
// by removing matching pairs of quotes and parentheses/brackets.
170210

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-
// Match the regexp against the input. If there are no matches, we can just return.
181-
const matches = [...str.matchAll(re)];
182-
if (matches.length === 0) {
183-
return str;
184-
}
185-
// Remove all but the last, non-nested pair of (), because () can affect
186-
// whether the previous word is seen as a keyword.
211+
// Specifically, remove all but the last, non-nested pair of (), because ()
212+
// can affect whether the previous word is seen as a keyword.
187213
// E.g.: When input is `function() {`, do not replace the ().
188214
// When input is `{ foo(); }`, do replace the `()`, then afterwards the `{ ... }`.
189-
let startsReplaceIndex = matches.length - 1;
190-
const lastMatch = matches[matches.length - 1];
191-
if (lastMatch[0].startsWith('(') && !str.substr(lastMatch.index + lastMatch[0].length).match(/[\)\]\}`'"]/)) {
192-
startsReplaceIndex--;
215+
const brackets = this._findAllMatchingBracketsIgnoreMismatches(str);
216+
if (brackets.length > 0) {
217+
const last = brackets[brackets.length - 1];
218+
if (last.kind === '(' && (last.parent == null || last.parent.end === -1))
219+
brackets.pop();
193220
}
194-
for (let i = startsReplaceIndex; i >= 0; i--) {
195-
// Replace str1<match>str2 with str1str2. Go backwards so that the match
196-
// indices into the string remain valid.
197-
str = str.substr(0, matches[i].index) + str.substr(matches[i].index + matches[i][0].length);
221+
// Remove brackets in reverse order, so that their indices remain valid.
222+
for (let i = brackets.length - 1; i >= 0; i--) {
223+
str = str.substr(0, brackets[i].start) + str.substr(brackets[i].end + 1);
198224
}
199225
return str;
200226
});
227+
228+
_findAllMatchingBracketsIgnoreMismatches = memoizeStringTransformerMethod(1000, function(str) {
229+
return findAllMatchingBrackets(str, true);
230+
});
231+
_findAllMatchingBracketsIncludeMismatches = memoizeStringTransformerMethod(1000, function(str) {
232+
return findAllMatchingBrackets(str, false);
233+
});
234+
235+
// Find the matching bracket opposite of the one at `position`.
236+
_findMatchingBracket(line, position) {
237+
const brackets = this._findAllMatchingBracketsIncludeMismatches(line);
238+
for (const bracket of brackets) {
239+
if (bracket.start === position)
240+
return bracket.end;
241+
if (bracket.end === position)
242+
return bracket.start;
243+
}
244+
return -1;
245+
}
201246
}
202247

203248
module.exports = {

0 commit comments

Comments
 (0)