repl: add repl.setupHistory for programmatic repl
Adds a `repl.setupHistory()` instance method so that programmatic REPLs can also write history to a file. This change also refactors all of the history file management to `lib/internal/repl/history.js`, cleaning up and simplifying `lib/internal/repl.js`. PR-URL: https://github.com/nodejs/node/pull/25895 Reviewed-By: Daniel Bevenius <daniel.bevenius@gmail.com>
This commit is contained in:
parent
902c71a9d0
commit
0aa74443d8
@ -448,6 +448,22 @@ deprecated: v9.0.0
|
||||
An internal method used to parse and execute `REPLServer` keywords.
|
||||
Returns `true` if `keyword` is a valid keyword, otherwise `false`.
|
||||
|
||||
### replServer.setupHistory(historyPath, callback)
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
* `historyPath` {string} the path to the history file
|
||||
* `callback` {Function} called when history writes are ready or upon error
|
||||
* `err` {Error}
|
||||
* `repl` {repl.REPLServer}
|
||||
|
||||
Initializes a history log file for the REPL instance. When executing the
|
||||
Node.js binary and using the command line REPL, a history file is initialized
|
||||
by default. However, this is not the case when creating a REPL
|
||||
programmatically. Use this method to initialize a history log file when working
|
||||
with REPL instances programmatically.
|
||||
|
||||
## repl.start([options])
|
||||
<!-- YAML
|
||||
added: v0.1.91
|
||||
|
@ -1,24 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const { Interface } = require('readline');
|
||||
const REPL = require('repl');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const util = require('util');
|
||||
const debug = util.debuglog('repl');
|
||||
|
||||
module.exports = Object.create(REPL);
|
||||
module.exports.createInternalRepl = createRepl;
|
||||
|
||||
// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary.
|
||||
// The debounce is to guard against code pasted into the REPL.
|
||||
const kDebounceHistoryMS = 15;
|
||||
|
||||
function _writeToOutput(repl, message) {
|
||||
repl._writeToOutput(message);
|
||||
repl._refreshLine();
|
||||
}
|
||||
|
||||
function createRepl(env, opts, cb) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
@ -55,151 +41,9 @@ function createRepl(env, opts, cb) {
|
||||
if (!Number.isNaN(historySize) && historySize > 0) {
|
||||
opts.historySize = historySize;
|
||||
} else {
|
||||
// XXX(chrisdickinson): set here to avoid affecting existing applications
|
||||
// using repl instances.
|
||||
opts.historySize = 1000;
|
||||
}
|
||||
|
||||
const repl = REPL.start(opts);
|
||||
if (opts.terminal) {
|
||||
return setupHistory(repl, env.NODE_REPL_HISTORY, cb);
|
||||
}
|
||||
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
cb(null, repl);
|
||||
}
|
||||
|
||||
function setupHistory(repl, historyPath, ready) {
|
||||
// Empty string disables persistent history
|
||||
if (typeof historyPath === 'string')
|
||||
historyPath = historyPath.trim();
|
||||
|
||||
if (historyPath === '') {
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
return ready(null, repl);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
debug(err.stack);
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
return ready(null, repl);
|
||||
}
|
||||
}
|
||||
|
||||
var timer = null;
|
||||
var writing = false;
|
||||
var 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);
|
||||
|
||||
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);
|
||||
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
repl.resume();
|
||||
return ready(null, repl);
|
||||
}
|
||||
fs.close(hnd, onclose);
|
||||
}
|
||||
|
||||
function onclose(err) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
fs.readFile(historyPath, 'utf8', onread);
|
||||
}
|
||||
|
||||
function onread(err, data) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
repl.history = data.split(/[\n\r]+/, repl.historySize);
|
||||
} else {
|
||||
repl.history = [];
|
||||
}
|
||||
|
||||
fs.open(historyPath, 'r+', onhandle);
|
||||
}
|
||||
|
||||
function onhandle(err, hnd) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
fs.ftruncate(hnd, 0, (err) => {
|
||||
repl._historyHandle = hnd;
|
||||
repl.on('line', online);
|
||||
|
||||
// Reading the file data out erases it
|
||||
repl.once('flushHistory', function() {
|
||||
repl.resume();
|
||||
ready(null, repl);
|
||||
});
|
||||
flushHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// ------ history listeners ------
|
||||
function online() {
|
||||
repl._flushing = true;
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(flushHistory, kDebounceHistoryMS);
|
||||
}
|
||||
|
||||
function flushHistory() {
|
||||
timer = null;
|
||||
if (writing) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
writing = true;
|
||||
const historyData = repl.history.join(os.EOL);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
this._historyPrev = Interface.prototype._historyPrev;
|
||||
return this._historyPrev();
|
||||
repl.setupHistory(opts.terminal ? env.NODE_REPL_HISTORY : '', cb);
|
||||
}
|
||||
|
153
lib/internal/repl/history.js
Normal file
153
lib/internal/repl/history.js
Normal file
@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
|
||||
const { Interface } = require('readline');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const util = require('util');
|
||||
const debug = util.debuglog('repl');
|
||||
|
||||
// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary.
|
||||
// The debounce is to guard against code pasted into the REPL.
|
||||
const kDebounceHistoryMS = 15;
|
||||
|
||||
module.exports = setupHistory;
|
||||
|
||||
function _writeToOutput(repl, message) {
|
||||
repl._writeToOutput(message);
|
||||
repl._refreshLine();
|
||||
}
|
||||
|
||||
function setupHistory(repl, historyPath, ready) {
|
||||
// Empty string disables persistent history
|
||||
if (typeof historyPath === 'string')
|
||||
historyPath = historyPath.trim();
|
||||
|
||||
if (historyPath === '') {
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
return ready(null, repl);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
debug(err.stack);
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
return ready(null, repl);
|
||||
}
|
||||
}
|
||||
|
||||
var timer = null;
|
||||
var writing = false;
|
||||
var 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);
|
||||
|
||||
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);
|
||||
|
||||
repl._historyPrev = _replHistoryMessage;
|
||||
repl.resume();
|
||||
return ready(null, repl);
|
||||
}
|
||||
fs.close(hnd, onclose);
|
||||
}
|
||||
|
||||
function onclose(err) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
fs.readFile(historyPath, 'utf8', onread);
|
||||
}
|
||||
|
||||
function onread(err, data) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
repl.history = data.split(/[\n\r]+/, repl.historySize);
|
||||
} else {
|
||||
repl.history = [];
|
||||
}
|
||||
|
||||
fs.open(historyPath, 'r+', onhandle);
|
||||
}
|
||||
|
||||
function onhandle(err, hnd) {
|
||||
if (err) {
|
||||
return ready(err);
|
||||
}
|
||||
fs.ftruncate(hnd, 0, (err) => {
|
||||
repl._historyHandle = hnd;
|
||||
repl.on('line', online);
|
||||
|
||||
// Reading the file data out erases it
|
||||
repl.once('flushHistory', function() {
|
||||
repl.resume();
|
||||
ready(null, repl);
|
||||
});
|
||||
flushHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// ------ history listeners ------
|
||||
function online(line) {
|
||||
repl._flushing = true;
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(flushHistory, kDebounceHistoryMS);
|
||||
}
|
||||
|
||||
function flushHistory() {
|
||||
timer = null;
|
||||
if (writing) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
writing = true;
|
||||
const historyData = repl.history.join(os.EOL);
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
this._historyPrev = Interface.prototype._historyPrev;
|
||||
return this._historyPrev();
|
||||
}
|
@ -82,6 +82,7 @@ const {
|
||||
startSigintWatchdog,
|
||||
stopSigintWatchdog
|
||||
} = internalBinding('util');
|
||||
const history = require('internal/repl/history');
|
||||
|
||||
// Lazy-loaded.
|
||||
let processTopLevelAwait;
|
||||
@ -762,6 +763,10 @@ exports.start = function(prompt,
|
||||
return repl;
|
||||
};
|
||||
|
||||
REPLServer.prototype.setupHistory = function setupHistory(historyFile, cb) {
|
||||
history(this, historyFile, cb);
|
||||
};
|
||||
|
||||
REPLServer.prototype.clearBufferedCommand = function clearBufferedCommand() {
|
||||
this[kBufferedCommandSymbol] = '';
|
||||
};
|
||||
|
1
node.gyp
1
node.gyp
@ -172,6 +172,7 @@
|
||||
'lib/internal/readline.js',
|
||||
'lib/internal/repl.js',
|
||||
'lib/internal/repl/await.js',
|
||||
'lib/internal/repl/history.js',
|
||||
'lib/internal/repl/recoverable.js',
|
||||
'lib/internal/socket_list.js',
|
||||
'lib/internal/test/binding.js',
|
||||
|
245
test/parallel/test-repl-programmatic-history.js
Normal file
245
test/parallel/test-repl-programmatic-history.js
Normal file
@ -0,0 +1,245 @@
|
||||
'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 path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
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}\n`);
|
||||
}
|
||||
setImmediate(doAction);
|
||||
};
|
||||
setImmediate(doAction);
|
||||
}
|
||||
resume() {}
|
||||
pause() {}
|
||||
}
|
||||
ActionStream.prototype.readable = true;
|
||||
|
||||
|
||||
// Mock keys
|
||||
const UP = { name: 'up' };
|
||||
const ENTER = { name: 'enter' };
|
||||
const CLEAR = { ctrl: true, name: 'u' };
|
||||
|
||||
// File paths
|
||||
const historyFixturePath = fixtures.path('.node_repl_history');
|
||||
const historyPath = path.join(tmpdir.path, '.fixture_copy_repl_history');
|
||||
const historyPathFail = fixtures.path('nonexistent_folder', 'filename');
|
||||
const defaultHistoryPath = path.join(tmpdir.path, '.node_repl_history');
|
||||
const emptyHiddenHistoryPath = fixtures.path('.empty-hidden-repl-history-file');
|
||||
const devNullHistoryPath = path.join(tmpdir.path,
|
||||
'.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 = [
|
||||
{
|
||||
env: { NODE_REPL_HISTORY: '' },
|
||||
test: [UP],
|
||||
expected: [prompt, replDisabled, prompt]
|
||||
},
|
||||
{
|
||||
env: { NODE_REPL_HISTORY: ' ' },
|
||||
test: [UP],
|
||||
expected: [prompt, replDisabled, prompt]
|
||||
},
|
||||
{
|
||||
env: { NODE_REPL_HISTORY: historyPath },
|
||||
test: [UP, CLEAR],
|
||||
expected: [prompt, `${prompt}'you look fabulous today'`, prompt]
|
||||
},
|
||||
{
|
||||
env: {},
|
||||
test: [UP, '\'42\'', ENTER],
|
||||
expected: [prompt, '\'', '4', '2', '\'', '\'42\'\n', prompt, prompt],
|
||||
clean: false
|
||||
},
|
||||
{ // Requires the above test case
|
||||
env: {},
|
||||
test: [UP, UP, ENTER],
|
||||
expected: [prompt, `${prompt}'42'`, '\'42\'\n', prompt]
|
||||
},
|
||||
{
|
||||
env: { NODE_REPL_HISTORY: historyPath,
|
||||
NODE_REPL_HISTORY_SIZE: 1 },
|
||||
test: [UP, UP, CLEAR],
|
||||
expected: [prompt, `${prompt}'you look fabulous today'`, prompt]
|
||||
},
|
||||
{
|
||||
env: { NODE_REPL_HISTORY: historyPathFail,
|
||||
NODE_REPL_HISTORY_SIZE: 1 },
|
||||
test: [UP],
|
||||
expected: [prompt, replFailedRead, prompt, replDisabled, prompt]
|
||||
},
|
||||
{
|
||||
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]
|
||||
},
|
||||
{
|
||||
before: function before() {
|
||||
if (!common.isWindows)
|
||||
fs.symlinkSync('/dev/null', devNullHistoryPath);
|
||||
},
|
||||
env: { NODE_REPL_HISTORY: devNullHistoryPath },
|
||||
test: [UP],
|
||||
expected: [prompt]
|
||||
},
|
||||
{ // Make sure this is always the last test, since we change os.homedir()
|
||||
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]
|
||||
}
|
||||
];
|
||||
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 historySize = opts.env.NODE_REPL_HISTORY_SIZE;
|
||||
const historyFile = 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,
|
||||
historySize: historySize
|
||||
});
|
||||
|
||||
repl.setupHistory(historyFile, function(err, repl) {
|
||||
if (err) {
|
||||
console.error(`Failed test # ${numtests - tests.length}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
repl.once('close', () => {
|
||||
if (repl._flushing) {
|
||||
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);
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user