Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repl: Enables history for programmatic REPLs. #5789

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 3 additions & 176 deletions lib/internal/repl.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
'use strict';

const Interface = require('readline').Interface;
const REPL = require('repl');
const path = require('path');
const fs = require('fs');
const os = require('os');
const debug = require('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 createRepl(env, opts, cb) {
if (typeof opts === 'function') {
cb = opts;
Expand Down Expand Up @@ -55,175 +46,11 @@ function createRepl(env, opts, cb) {
}

const repl = REPL.start(opts);

if (opts.terminal) {
return setupHistory(repl, env.NODE_REPL_HISTORY,
env.NODE_REPL_HISTORY_FILE, cb);
return repl.setupHistory(env.NODE_REPL_HISTORY,
env.NODE_REPL_HISTORY_FILE, cb);
}

repl._historyPrev = _replHistoryMessage;
cb(null, repl);
}

function setupHistory(repl, historyPath, oldHistoryPath, 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) {
repl._writeToOutput('\nError: Could not get the home directory.\n' +
'REPL session history will not be persisted.\n');
repl._refreshLine();

debug(err.stack);
repl._historyPrev = _replHistoryMessage;
return ready(null, repl);
}
}

var timer = null;
var writing = false;
var pending = false;
repl.pause();
fs.open(historyPath, 'a+', oninit);

function oninit(err, hnd) {
if (err) {
// Cannot open history file.
// Don't crash, just don't persist history.
repl._writeToOutput('\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n');
repl._refreshLine();
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 if (oldHistoryPath === historyPath) {
// If pre-v3.0, the user had set NODE_REPL_HISTORY_FILE to
// ~/.node_repl_history, warn the user about it and proceed.
repl._writeToOutput(
'\nThe old repl history file has the same name and location as ' +
`the new one i.e., ${historyPath} and is empty.\nUsing it as is.\n`);
repl._refreshLine();

} else if (oldHistoryPath) {
// Grab data from the older pre-v3.0 JSON NODE_REPL_HISTORY_FILE format.
repl._writeToOutput(
'\nConverting old JSON repl history to line-separated history.\n' +
`The new repl history file can be found at ${historyPath}.\n`);
repl._refreshLine();

try {
// Pre-v3.0, repl history was stored as JSON.
// Try and convert it to line separated history.
const oldReplJSONHistory = fs.readFileSync(oldHistoryPath, 'utf8');

// Only attempt to use the history if there was any.
if (oldReplJSONHistory) repl.history = JSON.parse(oldReplJSONHistory);

if (!Array.isArray(repl.history)) {
throw new Error('Expected array, got ' + typeof repl.history);
}
repl.history = repl.history.slice(0, repl.historySize);
} catch (err) {
if (err.code !== 'ENOENT') {
return ready(
new Error(`Could not parse history data in ${oldHistoryPath}.`));
}
}
}

fs.open(historyPath, 'w', onhandle);
}

function onhandle(err, hnd) {
if (err) {
return ready(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) {
this._writeToOutput(
'\nPersistent history support disabled. ' +
'Set the NODE_REPL_HISTORY environment\nvariable to ' +
'a valid, user-writable path to enable.\n'
);
this._refreshLine();
}
this._historyPrev = Interface.prototype._historyPrev;
return this._historyPrev();
}
174 changes: 173 additions & 1 deletion lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const Console = require('console').Console;
const Module = require('module');
const domain = require('domain');
const debug = util.debuglog('repl');

const os = require('os');
const parentModule = module;
const replMap = new WeakMap();

Expand Down Expand Up @@ -1141,6 +1141,165 @@ REPLServer.prototype.convertToContext = function(cmd) {
return cmd;
};

REPLServer.prototype.setupHistory = function(historyPath, oldHistoryPath, cb) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At very least, this would have to be refactored so that only the internal one uses oldHistoryPath -- no good in exposing deprecated functionality. :)

Another question this bears is, does the repl try to do this when you create it, or do you have to tell it to? The latter potentially has a race condition I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. My first jab at this was to keep the existing functionality as similar as possible, in order to get the discussion going without making assumptions or rocking the boat. FWIW, in this PR, oldHistoryPath is optional, so it wouldn't be required by the end user. But yes, it's cleaner to not expose this.

Currently, this is user controlled after the REPL has been created - similar to how it is now in createInternalRepl. I suppose, exposing it to the user as a post-creation action does introduce the race potential.

const repl = this;

// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary.
// The debounce is to guard against code pasted into the REPL.
const kDebounceHistoryMS = 15;

if (typeof oldHistoryPath === 'function') {
cb = oldHistoryPath;
oldHistoryPath = null;
}

if (typeof historyPath === 'string')
historyPath = historyPath.trim();

// Empty string disables persistent history.
if (historyPath === '') {
repl._historyPrev = _replHistoryMessage;
return cb(null, repl);
}

if (!historyPath) {
try {
historyPath = path.join(os.homedir(), '.node_repl_history');
} catch (err) {
repl._writeToOutput('\nError: Could not get the home directory.\n' +
'REPL session history will not be persisted.\n');
repl._refreshLine();

debug(err.stack);
repl._historyPrev = _replHistoryMessage;
return cb(null, repl);
}
}

var timer = null;
var writing = false;
var pending = false;
repl.pause();
fs.open(historyPath, 'a+', oninit);

function oninit(err, hnd) {
if (err) {
// Cannot open history file.
// Don't crash, just don't persist history.
repl._writeToOutput('\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n');
repl._refreshLine();
debug(err.stack);

repl._historyPrev = _replHistoryMessage;
repl.resume();
return cb(null, repl);
}
fs.close(hnd, onclose);
}

function onclose(err) {
if (err) {
return cb(err);
}
fs.readFile(historyPath, 'utf8', onread);
}

function onread(err, data) {
if (err) {
return cb(err);
}

if (data) {
repl.history = data.split(/[\n\r]+/, repl.historySize);
} else if (oldHistoryPath === historyPath) {
// If pre-v3.0, the user had set NODE_REPL_HISTORY_FILE to
// ~/.node_repl_history, warn the user about it and proceed.
repl._writeToOutput(
'\nThe old repl history file has the same name and location as ' +
`the new one i.e., ${historyPath} and is empty.\nUsing it as is.\n`);
repl._refreshLine();

} else if (oldHistoryPath) {
// Grab data from the older pre-v3.0 JSON NODE_REPL_HISTORY_FILE format.
repl._writeToOutput(
'\nConverting old JSON repl history to line-separated history.\n' +
`The new repl history file can be found at ${historyPath}.\n`);
repl._refreshLine();

try {
// Pre-v3.0, repl history was stored as JSON.
// Try and convert it to line separated history.
const oldReplJSONHistory = fs.readFileSync(oldHistoryPath, 'utf8');

// Only attempt to use the history if there was any.
if (oldReplJSONHistory) repl.history = JSON.parse(oldReplJSONHistory);

if (!Array.isArray(repl.history)) {
throw new Error('Expected array, got ' + typeof repl.history);
}
repl.history = repl.history.slice(0, repl.historySize);
} catch (err) {
if (err.code !== 'ENOENT') {
return cb(
new Error(`Could not parse history data in ${oldHistoryPath}.`));
}
}
}

fs.open(historyPath, 'w', onhandle);
}

function onhandle(err, hnd) {
if (err) {
return cb(err);
}
repl._historyHandle = hnd;
repl.on('line', online);

// reading the file data out erases it
repl.once('flushHistory', function() {
repl.resume();
cb(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');
}
}
}
};

// If the error is that we've unexpectedly ended the input,
// then let the user try to recover by adding more input.
Expand All @@ -1164,3 +1323,16 @@ function Recoverable(err) {
this.err = err;
}
inherits(Recoverable, SyntaxError);

function _replHistoryMessage() {
if (this.history.length === 0) {
this._writeToOutput(
'\nPersistent history support disabled. ' +
'Set the NODE_REPL_HISTORY environment\nvariable to ' +
'a valid, user-writable path to enable.\n'
);
this._refreshLine();
}
this._historyPrev = Interface.prototype._historyPrev;
return this._historyPrev();
}