1+ 'use strict' ;
12const repl = require ( 'repl' ) ;
23const 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
550class 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
63184module . exports = {
0 commit comments