From 70adc77807065826544855738f110fc8dd7d9873 Mon Sep 17 00:00:00 2001 From: Giovanni Date: Tue, 27 May 2025 11:17:25 +0200 Subject: [PATCH] repl: extract and standardize history from both repl and interface --- doc/api/repl.md | 25 +- lib/internal/main/repl.js | 2 +- lib/internal/readline/interface.js | 192 +++---- lib/internal/repl.js | 19 +- lib/internal/repl/history.js | 467 +++++++++++++----- lib/repl.js | 18 +- test/parallel/test-repl-persistent-history.js | 2 +- ...repl-programmatic-history-setup-history.js | 281 +++++++++++ .../test-repl-programmatic-history.js | 8 +- 9 files changed, 747 insertions(+), 267 deletions(-) create mode 100644 test/parallel/test-repl-programmatic-history-setup-history.js diff --git a/doc/api/repl.md b/doc/api/repl.md index 546ca2aa2b4e92..24c14c59359b4c 100644 --- a/doc/api/repl.md +++ b/doc/api/repl.md @@ -645,14 +645,35 @@ buffered but not yet executed. This method is primarily intended to be called from within the action function for commands registered using the `replServer.defineCommand()` method. -### `replServer.setupHistory(historyPath, callback)` +### `replServer.setupHistory(historyConfig, callback)` -* `historyPath` {string} the path to the history file +* `historyConfig` {Object|string} the path to the history file + If it is a string, it is the path to the history file. + If it is an object, it can have the following properties: + * `filePath` {string} the path to the history file + * `size` {number} Maximum number of history lines retained. To disable + the history set this value to `0`. This option makes sense only if + `terminal` is set to `true` by the user or by an internal `output` check, + otherwise the history caching mechanism is not initialized at all. + **Default:** `30`. + * `removeHistoryDuplicates` {boolean} If `true`, when a new input line added + to the history list duplicates an older one, this removes the older line + from the list. **Default:** `false`. + * `onHistoryFileLoaded` {Function} called when history writes are ready or upon error + * `err` {Error} + * `repl` {repl.REPLServer} * `callback` {Function} called when history writes are ready or upon error + (Optional if provided as `onHistoryFileLoaded` in `historyConfig`) * `err` {Error} * `repl` {repl.REPLServer} diff --git a/lib/internal/main/repl.js b/lib/internal/main/repl.js index 7bf0eeec90fac9..087ecc0ae0971f 100644 --- a/lib/internal/main/repl.js +++ b/lib/internal/main/repl.js @@ -44,7 +44,7 @@ if (process.env.NODE_REPL_EXTERNAL_MODULE) { throw err; } repl.on('exit', () => { - if (repl._flushing) { + if (repl.historyManager.isFlushing) { return repl.once('flushHistory', () => { process.exit(); }); diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index ccd4073e288417..4a9272c0950243 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -3,14 +3,12 @@ const { ArrayFrom, ArrayPrototypeFilter, - ArrayPrototypeIndexOf, ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePop, ArrayPrototypePush, ArrayPrototypeReverse, ArrayPrototypeShift, - ArrayPrototypeSplice, ArrayPrototypeUnshift, DateNow, FunctionPrototypeCall, @@ -19,6 +17,7 @@ const { MathMax, MathMaxApply, NumberIsFinite, + ObjectDefineProperty, ObjectSetPrototypeOf, RegExpPrototypeExec, SafeStringIterator, @@ -30,7 +29,6 @@ const { StringPrototypeSlice, StringPrototypeSplit, StringPrototypeStartsWith, - StringPrototypeTrim, Symbol, SymbolAsyncIterator, SymbolDispose, @@ -46,8 +44,6 @@ const { const { validateAbortSignal, - validateArray, - validateNumber, validateString, validateUint32, } = require('internal/validators'); @@ -67,7 +63,6 @@ const { charLengthLeft, commonPrefix, kSubstringSearch, - reverseString, } = require('internal/readline/utils'); let emitKeypressEvents; let kFirstEventParam; @@ -78,8 +73,8 @@ const { } = require('internal/readline/callbacks'); const { StringDecoder } = require('string_decoder'); +const { ReplHistory } = require('internal/repl/history'); -const kHistorySize = 30; const kMaxUndoRedoStackSize = 2048; const kMincrlfDelay = 100; /** @@ -153,7 +148,6 @@ const kWriteToOutput = Symbol('_writeToOutput'); const kYank = Symbol('_yank'); const kYanking = Symbol('_yanking'); const kYankPop = Symbol('_yankPop'); -const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings'); const kSavePreviousState = Symbol('_savePreviousState'); const kRestorePreviousState = Symbol('_restorePreviousState'); const kPreviousLine = Symbol('_previousLine'); @@ -175,9 +169,6 @@ function InterfaceConstructor(input, output, completer, terminal) { FunctionPrototypeCall(EventEmitter, this); - let history; - let historySize; - let removeHistoryDuplicates = false; let crlfDelay; let prompt = '> '; let signal; @@ -187,14 +178,17 @@ function InterfaceConstructor(input, output, completer, terminal) { output = input.output; completer = input.completer; terminal = input.terminal; - history = input.history; - historySize = input.historySize; signal = input.signal; + + // It is possible to configure the history through the input object + const historySize = input.historySize; + const history = input.history; + const removeHistoryDuplicates = input.removeHistoryDuplicates; + if (input.tabSize !== undefined) { validateUint32(input.tabSize, 'tabSize', true); this.tabSize = input.tabSize; } - removeHistoryDuplicates = input.removeHistoryDuplicates; if (input.prompt !== undefined) { prompt = input.prompt; } @@ -215,24 +209,18 @@ function InterfaceConstructor(input, output, completer, terminal) { crlfDelay = input.crlfDelay; input = input.input; - } - if (completer !== undefined && typeof completer !== 'function') { - throw new ERR_INVALID_ARG_VALUE('completer', completer); + input.size = historySize; + input.history = history; + input.removeHistoryDuplicates = removeHistoryDuplicates; } - if (history === undefined) { - history = []; - } else { - validateArray(history, 'history'); - } + this.setupHistoryManager(input); - if (historySize === undefined) { - historySize = kHistorySize; + if (completer !== undefined && typeof completer !== 'function') { + throw new ERR_INVALID_ARG_VALUE('completer', completer); } - validateNumber(historySize, 'historySize', 0); - // Backwards compat; check the isTTY prop of the output stream // when `terminal` was not specified if (terminal === undefined && !(output === null || output === undefined)) { @@ -248,8 +236,6 @@ function InterfaceConstructor(input, output, completer, terminal) { this.input = input; this[kUndoStack] = []; this[kRedoStack] = []; - this.history = history; - this.historySize = historySize; this[kPreviousCursorCols] = -1; // The kill ring is a global list of blocks of text that were previously @@ -260,7 +246,6 @@ function InterfaceConstructor(input, output, completer, terminal) { this[kKillRing] = []; this[kKillRingCursor] = 0; - this.removeHistoryDuplicates = !!removeHistoryDuplicates; this.crlfDelay = crlfDelay ? MathMax(kMincrlfDelay, crlfDelay) : kMincrlfDelay; @@ -270,7 +255,6 @@ function InterfaceConstructor(input, output, completer, terminal) { this.terminal = !!terminal; - function onerror(err) { self.emit('error', err); } @@ -349,8 +333,6 @@ function InterfaceConstructor(input, output, completer, terminal) { // Cursor position on the line. this.cursor = 0; - this.historyIndex = -1; - if (output !== null && output !== undefined) output.on('resize', onresize); @@ -403,6 +385,36 @@ class Interface extends InterfaceConstructor { return this[kPrompt]; } + setupHistoryManager(options) { + this.historyManager = new ReplHistory(this, options); + + if (options.onHistoryFileLoaded) { + this.historyManager.initialize(options.onHistoryFileLoaded); + } + + ObjectDefineProperty(this, 'history', { + __proto__: null, configurable: true, enumerable: true, + get() { return this.historyManager.history; }, + set(newHistory) { return this.historyManager.history = newHistory; }, + }); + + ObjectDefineProperty(this, 'historyIndex', { + __proto__: null, configurable: true, enumerable: true, + get() { return this.historyManager.index; }, + set(historyIndex) { return this.historyManager.index = historyIndex; }, + }); + + ObjectDefineProperty(this, 'historySize', { + __proto__: null, configurable: true, enumerable: true, + get() { return this.historyManager.size; }, + }); + + ObjectDefineProperty(this, 'isFlushing', { + __proto__: null, configurable: true, enumerable: true, + get() { return this.historyManager.isFlushing; }, + }); + } + [kSetRawMode](mode) { const wasInRawMode = this.input.isRaw; @@ -478,70 +490,8 @@ class Interface extends InterfaceConstructor { } } - // Convert newlines to a consistent format for history storage - [kNormalizeHistoryLineEndings](line, from, to, reverse = true) { - // Multiline history entries are saved reversed - // History is structured with the newest entries at the top - // and the oldest at the bottom. Multiline histories, however, only occupy - // one line in the history file. When loading multiline history with - // an old node binary, the history will be saved in the old format. - // This is why we need to reverse the multilines. - // Reversing the multilines is necessary when adding / editing and displaying them - if (reverse) { - // First reverse the lines for proper order, then convert separators - return reverseString(line, from, to); - } - // For normal cases (saving to history or non-multiline entries) - return StringPrototypeReplaceAll(line, from, to); - } - [kAddHistory]() { - if (this.line.length === 0) return ''; - - // If the history is disabled then return the line - if (this.historySize === 0) return this.line; - - // If the trimmed line is empty then return the line - if (StringPrototypeTrim(this.line).length === 0) return this.line; - - // This is necessary because each line would be saved in the history while creating - // A new multiline, and we don't want that. - if (this[kIsMultiline] && this.historyIndex === -1) { - ArrayPrototypeShift(this.history); - } else if (this[kLastCommandErrored]) { - // If the last command errored and we are trying to edit the history to fix it - // Remove the broken one from the history - ArrayPrototypeShift(this.history); - } - - const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true); - - if (this.history.length === 0 || this.history[0] !== normalizedLine) { - if (this.removeHistoryDuplicates) { - // Remove older history line if identical to new one - const dupIndex = ArrayPrototypeIndexOf(this.history, this.line); - if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1); - } - - // Add the new line to the history - ArrayPrototypeUnshift(this.history, normalizedLine); - - // Only store so many - if (this.history.length > this.historySize) - ArrayPrototypePop(this.history); - } - - this.historyIndex = -1; - - // The listener could change the history object, possibly - // to remove the last added entry if it is sensitive and should - // not be persisted in the history, like a password - const line = this[kIsMultiline] ? reverseString(this.history[0]) : this.history[0]; - - // Emit history event to notify listeners of update - this.emit('history', this.history); - - return line; + return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]); } [kRefreshLine]() { @@ -1184,26 +1134,12 @@ class Interface extends InterfaceConstructor { // + N. Only show this after two/three UPs or DOWNs, not on the first // one. [kHistoryNext]() { - if (this.historyIndex >= 0) { - this[kBeforeEdit](this.line, this.cursor); - const search = this[kSubstringSearch] || ''; - let index = this.historyIndex - 1; - while ( - index >= 0 && - (!StringPrototypeStartsWith(this.history[index], search) || - this.line === this.history[index]) - ) { - index--; - } - if (index === -1) { - this[kSetLine](search); - } else { - this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n')); - } - this.historyIndex = index; - this.cursor = this.line.length; // Set cursor to end of line. - this[kRefreshLine](); - } + if (!this.historyManager.canNavigateToNext()) { return; } + + this[kBeforeEdit](this.line, this.cursor); + this[kSetLine](this.historyManager.navigateToNext(this[kSubstringSearch])); + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); } [kMoveUpOrHistoryPrev]() { @@ -1218,26 +1154,12 @@ class Interface extends InterfaceConstructor { } [kHistoryPrev]() { - if (this.historyIndex < this.history.length && this.history.length) { - this[kBeforeEdit](this.line, this.cursor); - const search = this[kSubstringSearch] || ''; - let index = this.historyIndex + 1; - while ( - index < this.history.length && - (!StringPrototypeStartsWith(this.history[index], search) || - this.line === this.history[index]) - ) { - index++; - } - if (index === this.history.length) { - this[kSetLine](search); - } else { - this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n')); - } - this.historyIndex = index; - this.cursor = this.line.length; // Set cursor to end of line. - this[kRefreshLine](); - } + if (!this.historyManager.canNavigateToPrevious()) { return; } + + this[kBeforeEdit](this.line, this.cursor); + this[kSetLine](this.historyManager.navigateToPrevious(this[kSubstringSearch])); + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); } // Returns the last character's display position of the given string diff --git a/lib/internal/repl.js b/lib/internal/repl.js index 2dc79b2784e189..2552aabf173e0d 100644 --- a/lib/internal/repl.js +++ b/lib/internal/repl.js @@ -40,14 +40,21 @@ function createRepl(env, opts, cb) { opts.replMode = REPL.REPL_MODE_SLOPPY; } - const historySize = Number(env.NODE_REPL_HISTORY_SIZE); - if (!NumberIsNaN(historySize) && historySize > 0) { - opts.historySize = historySize; + const size = Number(env.NODE_REPL_HISTORY_SIZE); + if (!NumberIsNaN(size) && size > 0) { + opts.size = size; } else { - opts.historySize = 1000; + opts.size = 1000; } - const repl = REPL.start(opts); const term = 'terminal' in opts ? opts.terminal : process.stdout.isTTY; - repl.setupHistory(term ? env.NODE_REPL_HISTORY : '', cb); + opts.filePath = term ? env.NODE_REPL_HISTORY : ''; + + const repl = REPL.start(opts); + + repl.setupHistory({ + filePath: opts.filePath, + size: opts.size, + onHistoryFileLoaded: cb, + }); } diff --git a/lib/internal/repl/history.js b/lib/internal/repl/history.js index a4fc4e376e990d..d05bb19d733a22 100644 --- a/lib/internal/repl/history.js +++ b/lib/internal/repl/history.js @@ -1,14 +1,21 @@ 'use strict'; const { + ArrayPrototypeIndexOf, ArrayPrototypeJoin, + ArrayPrototypePop, + ArrayPrototypeShift, + ArrayPrototypeSplice, + ArrayPrototypeUnshift, Boolean, - FunctionPrototype, RegExpPrototypeSymbolSplit, + StringPrototypeStartsWith, StringPrototypeTrim, + Symbol, } = primordials; -const { Interface } = require('readline'); +const { validateNumber, validateArray } = require('internal/validators'); + const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -17,168 +24,400 @@ let debug = require('internal/util/debuglog').debuglog('repl', (fn) => { }); const permission = require('internal/process/permission'); const { clearTimeout, setTimeout } = require('timers'); +const { + reverseString, +} = require('internal/readline/utils'); -const noop = FunctionPrototype; - -// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary. // The debounce is to guard against code pasted into the REPL. const kDebounceHistoryMS = 15; +const kHistorySize = 30; -module.exports = setupHistory; +// Class fields +const kTimer = Symbol('_kTimer'); +const kWriting = Symbol('_kWriting'); +const kPending = Symbol('_kPending'); +const kRemoveHistoryDuplicates = Symbol('_kRemoveHistoryDuplicates'); +const kHistoryHandle = Symbol('_kHistoryHandle'); +const kHistoryPath = Symbol('_kHistoryPath'); +const kContext = Symbol('_kContext'); +const kIsFlushing = Symbol('_kIsFlushing'); +const kHistory = Symbol('_kHistory'); +const kSize = Symbol('_kSize'); +const kIndex = Symbol('_kIndex'); -function _writeToOutput(repl, message) { - repl._writeToOutput(message); - repl._refreshLine(); -} +// Class methods +const kNormalizeLineEndings = Symbol('_kNormalizeLineEndings'); +const kWriteToOutput = Symbol('_kWriteToOutput'); +const kOnLine = Symbol('_kOnLine'); +const kOnExit = Symbol('_kOnExit'); +const kInitializeHistory = Symbol('_kInitializeHistory'); +const kHandleHistoryInitError = Symbol('_kHandleHistoryInitError'); +const kHasWritePermission = Symbol('_kHasWritePermission'); +const kValidateOptions = Symbol('_kValidateOptions'); +const kResolveHistoryPath = Symbol('_kResolveHistoryPath'); +const kReplHistoryMessage = Symbol('_kReplHistoryMessage'); +const kFlushHistory = Symbol('_kFlushHistory'); +const kGetHistoryPath = Symbol('_kGetHistoryPath'); -function setupHistory(repl, historyPath, ready) { - // Empty string disables persistent history - if (typeof historyPath === 'string') - historyPath = StringPrototypeTrim(historyPath); +class ReplHistory { + constructor(context, options) { + this[kValidateOptions](options); - if (historyPath === '') { - repl._historyPrev = _replHistoryMessage; - return ready(null, repl); + this[kHistoryPath] = ReplHistory[kGetHistoryPath](options); + this[kContext] = context; + this[kTimer] = null; + this[kWriting] = false; + this[kPending] = false; + this[kRemoveHistoryDuplicates] = options.removeHistoryDuplicates || false; + this[kHistoryHandle] = null; + this[kIsFlushing] = false; + this[kSize] = options.size ?? context.historySize ?? kHistorySize; + this[kHistory] = options.history ?? []; + this[kIndex] = -1; } - if (!historyPath) { - try { - historyPath = path.join(os.homedir(), '.node_repl_history'); - } catch (err) { - _writeToOutput(repl, '\nError: Could not get the home directory.\n' + - 'REPL session history will not be persisted.\n'); + initialize(onReadyCallback) { + // Empty string disables persistent history + if (this[kHistoryPath] === '') { + // Save a reference to the context's original _historyPrev + this.historyPrev = this[kContext]._historyPrev; + this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this); + return onReadyCallback(null, this[kContext]); + } + + const resolvedPath = this[kResolveHistoryPath](); + if (!resolvedPath) { + ReplHistory[kWriteToOutput]( + this[kContext], + '\nError: Could not get the home directory.\n' + + 'REPL session history will not be persisted.\n', + ); - debug(err.stack); - repl._historyPrev = _replHistoryMessage; - return ready(null, repl); + // Save a reference to the context's original _historyPrev + this.historyPrev = this[kContext]._historyPrev; + this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this); + return onReadyCallback(null, this[kContext]); } + + if (!this[kHasWritePermission]()) { + ReplHistory[kWriteToOutput]( + this[kContext], + '\nAccess to FileSystemWrite is restricted.\n' + + 'REPL session history will not be persisted.\n', + ); + return onReadyCallback(null, this[kContext]); + } + + this[kContext].pause(); + + this[kInitializeHistory](onReadyCallback).catch((err) => { + this[kHandleHistoryInitError](err, onReadyCallback); + }); } - if (permission.isEnabled() && permission.has('fs.write', historyPath) === false) { - _writeToOutput(repl, '\nAccess to FileSystemWrite is restricted.\n' + - 'REPL session history will not be persisted.\n'); - return ready(null, repl); + addHistory(isMultiline, lastCommandErrored) { + const line = this[kContext].line; + + if (line.length === 0) return ''; + + // If the history is disabled then return the line + if (this[kSize] === 0) return line; + + // If the trimmed line is empty then return the line + if (StringPrototypeTrim(line).length === 0) return line; + + // This is necessary because each line would be saved in the history while creating + // a new multiline, and we don't want that. + if (isMultiline && this[kIndex] === -1) { + ArrayPrototypeShift(this[kHistory]); + } else if (lastCommandErrored) { + // If the last command errored and we are trying to edit the history to fix it + // remove the broken one from the history + ArrayPrototypeShift(this[kHistory]); + } + + const normalizedLine = ReplHistory[kNormalizeLineEndings](line, '\n', '\r'); + + if (this[kHistory].length === 0 || this[kHistory][0] !== normalizedLine) { + if (this[kRemoveHistoryDuplicates]) { + // Remove older history line if identical to new one + const dupIndex = ArrayPrototypeIndexOf(this[kHistory], line); + if (dupIndex !== -1) ArrayPrototypeSplice(this[kHistory], dupIndex, 1); + } + + // Add the new line to the history + ArrayPrototypeUnshift(this[kHistory], normalizedLine); + + // Only store so many + if (this[kHistory].length > this[kSize]) + ArrayPrototypePop(this[kHistory]); + } + + this[kIndex] = -1; + + const finalLine = isMultiline ? reverseString(this[kHistory][0]) : this[kHistory][0]; + + // The listener could change the history object, possibly + // to remove the last added entry if it is sensitive and should + // not be persisted in the history, like a password + // Emit history event to notify listeners of update + this[kContext].emit('history', this[kHistory]); + + return finalLine; } - let timer = null; - let writing = false; - let pending = false; - repl.pause(); - // History files are conventionally not readable by others: - // https://github.com/nodejs/node/issues/3392 - // https://github.com/nodejs/node/pull/3394 - fs.open(historyPath, 'a+', 0o0600, oninit); + canNavigateToNext() { + return this[kIndex] > -1 && this[kHistory].length > 0; + } + + navigateToNext(substringSearch) { + if (!this.canNavigateToNext()) { + return null; + } + const search = substringSearch || ''; + let index = this[kIndex] - 1; + + while ( + index >= 0 && + (!StringPrototypeStartsWith(this[kHistory][index], search) || + this[kContext].line === this[kHistory][index]) + ) { + index--; + } + + this[kIndex] = index; + + if (index === -1) { + return search; + } + + return ReplHistory[kNormalizeLineEndings](this[kHistory][index], '\r', '\n'); + } + + canNavigateToPrevious() { + return this[kHistory].length !== this[kIndex] && this[kHistory].length > 0; + } + + navigateToPrevious(substringSearch = '') { + if (!this.canNavigateToPrevious()) { + return null; + } + const search = substringSearch || ''; + let index = this[kIndex] + 1; + + while ( + index < this[kHistory].length && + (!StringPrototypeStartsWith(this[kHistory][index], search) || + this[kContext].line === this[kHistory][index]) + ) { + index++; + } - function oninit(err, hnd) { - if (err) { - // Cannot open history file. - // Don't crash, just don't persist history. - _writeToOutput(repl, '\nError: Could not open history file.\n' + - 'REPL session history will not be persisted.\n'); - debug(err.stack); + this[kIndex] = index; - repl._historyPrev = _replHistoryMessage; - repl.resume(); - return ready(null, repl); + if (index === this[kHistory].length) { + return search; } - fs.close(hnd, onclose); + + return ReplHistory[kNormalizeLineEndings](this[kHistory][index], '\r', '\n'); } - function onclose(err) { - if (err) { - return ready(err); + get size() { return this[kSize]; } + get isFlushing() { return this[kIsFlushing]; } + get history() { return this[kHistory]; } + set history(value) { this[kHistory] = value; } + get index() { return this[kIndex]; } + set index(value) { this[kIndex] = value; } + + // Start private methods + + static [kGetHistoryPath](options) { + let historyPath = options.filePath; + if (typeof historyPath === 'string') { + historyPath = StringPrototypeTrim(historyPath); } - fs.readFile(historyPath, 'utf8', onread); + return historyPath; + } + + static [kNormalizeLineEndings](line, from, to) { + // Multiline history entries are saved reversed + // History is structured with the newest entries at the top + // and the oldest at the bottom. Multiline histories, however, only occupy + // one line in the history file. When loading multiline history with + // an old node binary, the history will be saved in the old format. + // This is why we need to reverse the multilines. + // Reversing the multilines is necessary when adding / editing and displaying them + return reverseString(line, from, to); } - function onread(err, data) { - if (err) { - return ready(err); + static [kWriteToOutput](context, message) { + if (typeof context._writeToOutput === 'function') { + context._writeToOutput(message); + if (typeof context._refreshLine === 'function') { + context._refreshLine(); + } } + } - if (data) { - repl.history = RegExpPrototypeSymbolSplit(/\r?\n+/, data, repl.historySize); - } else { - repl.history = []; + [kResolveHistoryPath]() { + if (!this[kHistoryPath]) { + try { + this[kHistoryPath] = path.join(os.homedir(), '.node_repl_history'); + return this[kHistoryPath]; + } catch (err) { + debug(err.stack); + return null; + } } + return this[kHistoryPath]; + } - fs.open(historyPath, 'r+', onhandle); + [kHasWritePermission]() { + return !(permission.isEnabled() && + permission.has('fs.write', this[kHistoryPath]) === false); } - function onhandle(err, hnd) { - if (err) { - return ready(err); + [kValidateOptions](options) { + if (typeof options.history !== 'undefined') { + validateArray(options.history, 'history'); + } + if (typeof options.size !== 'undefined') { + validateNumber(options.size, 'size', 0); } - fs.ftruncate(hnd, 0, (err) => { - repl._historyHandle = hnd; - repl.on('line', online); - repl.once('exit', onexit); + } + + async [kInitializeHistory](onReadyCallback) { + try { + // Open and close file first to ensure it exists + // History files are conventionally not readable by others + // 0o0600 = read/write for owner only + const hnd = await fs.promises.open(this[kHistoryPath], 'a+', 0o0600); + await hnd.close(); + + let data; + try { + data = await fs.promises.readFile(this[kHistoryPath], 'utf8'); + } catch (err) { + return this[kHandleHistoryInitError](err, onReadyCallback); + } + + if (data) { + this[kHistory] = RegExpPrototypeSymbolSplit(/\r?\n+/, data, this[kSize]); + } else { + this[kHistory] = []; + } + + validateArray(this[kHistory], 'history'); + + const handle = await fs.promises.open(this[kHistoryPath], 'r+'); + this[kHistoryHandle] = handle; + + await handle.truncate(0); + + this[kContext].on('line', this[kOnLine].bind(this)); + this[kContext].once('exit', this[kOnExit].bind(this)); - // Reading the file data out erases it - repl.once('flushHistory', function() { - if (!repl.closed) { - repl.resume(); - ready(null, repl); + this[kContext].once('flushHistory', () => { + if (!this[kContext].closed) { + this[kContext].resume(); + onReadyCallback(null, this[kContext]); } }); - flushHistory(); - }); + + await this[kFlushHistory](); + } catch (err) { + return this[kHandleHistoryInitError](err, onReadyCallback); + } } - // ------ history listeners ------ - function online(line) { - repl._flushing = true; + [kHandleHistoryInitError](err, onReadyCallback) { + // Cannot open history file. + // Don't crash, just don't persist history. + ReplHistory[kWriteToOutput]( + this[kContext], + '\nError: Could not open history file.\n' + + 'REPL session history will not be persisted.\n', + ); + debug(err.stack); - if (timer) { - clearTimeout(timer); + // Save a reference to the context's original _historyPrev + this.historyPrev = this[kContext]._historyPrev; + this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this); + this[kContext].resume(); + return onReadyCallback(null, this[kContext]); + } + + [kOnLine]() { + this[kIsFlushing] = true; + + if (this[kTimer]) { + clearTimeout(this[kTimer]); } - timer = setTimeout(flushHistory, kDebounceHistoryMS); + this[kTimer] = setTimeout(() => this[kFlushHistory](), kDebounceHistoryMS); } - function flushHistory() { - timer = null; - if (writing) { - pending = true; + async [kFlushHistory]() { + this[kTimer] = null; + if (this[kWriting]) { + this[kPending] = true; return; } - writing = true; - const historyData = ArrayPrototypeJoin(repl.history, '\n'); - fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten); - } - function onwritten(err, data) { - writing = false; - if (pending) { - pending = false; - online(); - } else { - repl._flushing = Boolean(timer); - if (!repl._flushing) { - repl.emit('flushHistory'); + this[kWriting] = true; + const historyData = ArrayPrototypeJoin(this[kHistory], '\n'); + + try { + await this[kHistoryHandle].write(historyData, 0, 'utf8'); + this[kWriting] = false; + + if (this[kPending]) { + this[kPending] = false; + this[kOnLine](); + } else { + this[kIsFlushing] = Boolean(this[kTimer]); + if (!this[kIsFlushing]) { + this[kContext].emit('flushHistory'); + } } + } catch (err) { + this[kWriting] = false; + debug('Error writing history file:', err); } } - function onexit() { - if (repl._flushing) { - repl.once('flushHistory', onexit); + async [kOnExit]() { + if (this[kIsFlushing]) { + this[kContext].once('flushHistory', this[kOnExit].bind(this)); return; } - repl.off('line', online); - fs.close(repl._historyHandle, noop); + this[kContext].off('line', this[kOnLine].bind(this)); + + if (this[kHistoryHandle] !== null) { + try { + await this[kHistoryHandle].close(); + } catch (err) { + debug('Error closing history file:', err); + } + } } -} -function _replHistoryMessage() { - if (this.history.length === 0) { - _writeToOutput( - this, - '\nPersistent history support disabled. ' + - 'Set the NODE_REPL_HISTORY environment\nvariable to ' + - 'a valid, user-writable path to enable.\n', - ); + [kReplHistoryMessage]() { + if (this[kHistory].length === 0) { + ReplHistory[kWriteToOutput]( + this[kContext], + '\nPersistent history support disabled. ' + + 'Set the NODE_REPL_HISTORY environment\nvariable to ' + + 'a valid, user-writable path to enable.\n', + ); + } + // First restore the original method on the context + this[kContext]._historyPrev = this.historyPrev; + // Then call it with the correct context + return this[kContext]._historyPrev(); } - this._historyPrev = Interface.prototype._historyPrev; - return this._historyPrev(); } + +module.exports = { + ReplHistory, +}; diff --git a/lib/repl.js b/lib/repl.js index 88fd10e3fbc9c9..6dfba9000e0790 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -186,7 +186,6 @@ const { stopSigintWatchdog, } = internalBinding('contextify'); -const history = require('internal/repl/history'); const { extensionFormatMap, } = require('internal/modules/esm/formats'); @@ -787,6 +786,8 @@ function REPLServer(prompt, [text, self.editorMode ? self.completeOnEditorMode(cb) : cb]); } + // All the parameters in the object are defining the "input" param of the + // InterfaceConstructor. ReflectApply(Interface, this, [{ input: options.input, output: options.output, @@ -1075,8 +1076,17 @@ function start(prompt, source, eval_, useGlobal, ignoreUndefined, replMode) { prompt, source, eval_, useGlobal, ignoreUndefined, replMode); } -REPLServer.prototype.setupHistory = function setupHistory(historyFile, cb) { - history(this, historyFile, cb); +REPLServer.prototype.setupHistory = function setupHistory(historyConfig = {}, cb) { + // TODO(puskin94): necessary because historyConfig can be a string for backwards compatibility + const options = typeof historyConfig === 'string' ? + { filePath: historyConfig } : + historyConfig; + + if (typeof cb === 'function') { + options.onHistoryFileLoaded = cb; + } + + this.setupHistoryManager(options); }; REPLServer.prototype.clearBufferedCommand = function clearBufferedCommand() { @@ -1084,7 +1094,7 @@ REPLServer.prototype.clearBufferedCommand = function clearBufferedCommand() { }; REPLServer.prototype.close = function close() { - if (this.terminal && this._flushing && !this._closingOnFlush) { + if (this.terminal && this.historyManager.isFlushing && !this._closingOnFlush) { this._closingOnFlush = true; this.once('flushHistory', () => ReflectApply(Interface.prototype.close, this, []), diff --git a/test/parallel/test-repl-persistent-history.js b/test/parallel/test-repl-persistent-history.js index 2ec5a315c8a7c3..efd1aa141357c2 100644 --- a/test/parallel/test-repl-persistent-history.js +++ b/test/parallel/test-repl-persistent-history.js @@ -242,7 +242,7 @@ function runTest(assertCleaned) { } repl.once('close', () => { - if (repl._flushing) { + if (repl.historyManager.isFlushing) { repl.once('flushHistory', onClose); return; } diff --git a/test/parallel/test-repl-programmatic-history-setup-history.js b/test/parallel/test-repl-programmatic-history-setup-history.js new file mode 100644 index 00000000000000..038972b8566ba0 --- /dev/null +++ b/test/parallel/test-repl-programmatic-history-setup-history.js @@ -0,0 +1,281 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const stream = require('stream'); +const REPL = require('repl'); +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); + +if (process.env.TERM === 'dumb') { + common.skip('skipping - dumb terminal'); +} + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Mock os.homedir() +os.homedir = function() { + return tmpdir.path; +}; + +// Create an input stream specialized for testing an array of actions +class ActionStream extends stream.Stream { + run(data) { + const _iter = data[Symbol.iterator](); + const doAction = () => { + const next = _iter.next(); + if (next.done) { + // Close the repl. Note that it must have a clean prompt to do so. + setImmediate(() => { + this.emit('keypress', '', { ctrl: true, name: 'd' }); + }); + return; + } + const action = next.value; + + if (typeof action === 'object') { + this.emit('keypress', '', action); + } else { + this.emit('data', action); + } + setImmediate(doAction); + }; + doAction(); + } + resume() {} + pause() {} +} +ActionStream.prototype.readable = true; + + +// Mock keys +const UP = { name: 'up' }; +const DOWN = { name: 'down' }; +const ENTER = { name: 'enter' }; +const CLEAR = { ctrl: true, name: 'u' }; + +// File paths +const historyFixturePath = fixtures.path('.node_repl_history'); +const historyPath = tmpdir.resolve('.fixture_copy_repl_history'); +const historyPathFail = fixtures.path('nonexistent_folder', 'filename'); +const defaultHistoryPath = tmpdir.resolve('.node_repl_history'); +const emptyHiddenHistoryPath = fixtures.path('.empty-hidden-repl-history-file'); +const devNullHistoryPath = tmpdir.resolve('.dev-null-repl-history-file'); +// Common message bits +const prompt = '> '; +const replDisabled = '\nPersistent history support disabled. Set the ' + + 'NODE_REPL_HISTORY environment\nvariable to a valid, ' + + 'user-writable path to enable.\n'; +const homedirErr = '\nError: Could not get the home directory.\n' + + 'REPL session history will not be persisted.\n'; +const replFailedRead = '\nError: Could not open history file.\n' + + 'REPL session history will not be persisted.\n'; + +const tests = [ + // Makes sure that, if the history file is empty, the history is disabled + { + env: { NODE_REPL_HISTORY: '' }, + test: [UP], + expected: [prompt, replDisabled, prompt] + }, + // Makes sure that, if the history file is empty (when trimmed), the history is disabled + { + env: { NODE_REPL_HISTORY: ' ' }, + test: [UP], + expected: [prompt, replDisabled, prompt] + }, + // Properly loads the history file + { + env: { NODE_REPL_HISTORY: historyPath }, + test: [UP, CLEAR], + expected: [prompt, `${prompt}'you look fabulous today'`, prompt] + }, + // Properly navigates newly added history items + { + env: {}, + test: [UP, '21', ENTER, "'42'", ENTER], + expected: [ + prompt, + '2', '1', '21\n', prompt, + "'", '4', '2', "'", "'42'\n", prompt, + ], + clean: false + }, + { // Requires the above test case, because navigating old history + env: {}, + test: [UP, UP, UP, DOWN, ENTER], + expected: [ + prompt, + `${prompt}'42'`, + `${prompt}21`, + prompt, + `${prompt}21`, + '21\n', + prompt, + ] + }, + // Making sure that only the configured number of history items are kept + { + env: { NODE_REPL_HISTORY: historyPath, + NODE_REPL_HISTORY_SIZE: 1 }, + test: [UP, UP, DOWN, CLEAR], + expected: [ + prompt, + `${prompt}'you look fabulous today'`, + prompt, + `${prompt}'you look fabulous today'`, + prompt, + ] + }, + // Making sure that the history file is not written to if it is not writable + { + env: { NODE_REPL_HISTORY: historyPathFail, + NODE_REPL_HISTORY_SIZE: 1 }, + test: [UP], + expected: [prompt, replFailedRead, prompt, replDisabled, prompt] + }, + // Checking the history file permissions + { + before: function before() { + if (common.isWindows) { + const execSync = require('child_process').execSync; + execSync(`ATTRIB +H "${emptyHiddenHistoryPath}"`, (err) => { + assert.ifError(err); + }); + } + }, + env: { NODE_REPL_HISTORY: emptyHiddenHistoryPath }, + test: [UP], + expected: [prompt] + }, + // Checking failures when os.homedir() fails + { + before: function before() { + // Mock os.homedir() failure + os.homedir = function() { + throw new Error('os.homedir() failure'); + }; + }, + env: {}, + test: [UP], + expected: [prompt, homedirErr, prompt, replDisabled, prompt] + }, + // Checking that the history file can be set to /dev/null + { + before: function before() { + if (!common.isWindows) + fs.symlinkSync('/dev/null', devNullHistoryPath); + }, + env: { NODE_REPL_HISTORY: devNullHistoryPath }, + test: [UP], + expected: [prompt] + }, +]; +const numtests = tests.length; + + +function cleanupTmpFile() { + try { + // Write over the file, clearing any history + fs.writeFileSync(defaultHistoryPath, ''); + } catch (err) { + if (err.code === 'ENOENT') return true; + throw err; + } + return true; +} + +// Copy our fixture to the tmp directory +fs.createReadStream(historyFixturePath) + .pipe(fs.createWriteStream(historyPath)).on('unpipe', () => runTest()); + +const runTestWrap = common.mustCall(runTest, numtests); + +function runTest(assertCleaned) { + const opts = tests.shift(); + if (!opts) return; // All done + + if (assertCleaned) { + try { + assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), ''); + } catch (e) { + if (e.code !== 'ENOENT') { + console.error(`Failed test # ${numtests - tests.length}`); + throw e; + } + } + } + + const test = opts.test; + const expected = opts.expected; + const clean = opts.clean; + const before = opts.before; + const size = opts.env.NODE_REPL_HISTORY_SIZE; + const filePath = opts.env.NODE_REPL_HISTORY; + + if (before) before(); + + const repl = REPL.start({ + input: new ActionStream(), + output: new stream.Writable({ + write(chunk, _, next) { + const output = chunk.toString(); + + // Ignore escapes and blank lines + if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output)) + return next(); + + try { + assert.strictEqual(output, expected.shift()); + } catch (err) { + console.error(`Failed test # ${numtests - tests.length}`); + throw err; + } + next(); + } + }), + prompt: prompt, + useColors: false, + terminal: true, + }); + + repl.setupHistory({ + size, + filePath, + onHistoryFileLoaded, + removeHistoryDuplicates: false + }); + + function onHistoryFileLoaded(err, repl) { + if (err) { + console.error(`Failed test # ${numtests - tests.length}`); + throw err; + } + + repl.once('close', () => { + if (repl.historyManager.isFlushing) { + repl.once('flushHistory', onClose); + return; + } + + onClose(); + }); + + function onClose() { + const cleaned = clean === false ? false : cleanupTmpFile(); + + try { + // Ensure everything that we expected was output + assert.strictEqual(expected.length, 0); + setImmediate(runTestWrap, cleaned); + } catch (err) { + console.error(`Failed test # ${numtests - tests.length}`); + throw err; + } + } + + repl.inputStream.run(test); + } +} diff --git a/test/parallel/test-repl-programmatic-history.js b/test/parallel/test-repl-programmatic-history.js index 4e8887e0e08d56..c762e83840e41c 100644 --- a/test/parallel/test-repl-programmatic-history.js +++ b/test/parallel/test-repl-programmatic-history.js @@ -204,7 +204,7 @@ function runTest(assertCleaned) { const clean = opts.clean; const before = opts.before; const historySize = opts.env.NODE_REPL_HISTORY_SIZE; - const historyFile = opts.env.NODE_REPL_HISTORY; + const file = opts.env.NODE_REPL_HISTORY; if (before) before(); @@ -230,17 +230,17 @@ function runTest(assertCleaned) { prompt: prompt, useColors: false, terminal: true, - historySize: historySize + historySize }); - repl.setupHistory(historyFile, function(err, repl) { + repl.setupHistory(file, function(err, repl) { if (err) { console.error(`Failed test # ${numtests - tests.length}`); throw err; } repl.once('close', () => { - if (repl._flushing) { + if (repl.historyManager.isFlushing) { repl.once('flushHistory', onClose); return; }