mirror of https://github.com/nodejs/node.git
424 lines
12 KiB
JavaScript
424 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayPrototypeIndexOf,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypePop,
|
|
ArrayPrototypeShift,
|
|
ArrayPrototypeSplice,
|
|
ArrayPrototypeUnshift,
|
|
Boolean,
|
|
RegExpPrototypeSymbolSplit,
|
|
StringPrototypeStartsWith,
|
|
StringPrototypeTrim,
|
|
Symbol,
|
|
} = primordials;
|
|
|
|
const { validateNumber, validateArray } = require('internal/validators');
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
|
|
debug = fn;
|
|
});
|
|
const permission = require('internal/process/permission');
|
|
const { clearTimeout, setTimeout } = require('timers');
|
|
const {
|
|
reverseString,
|
|
} = require('internal/readline/utils');
|
|
|
|
// The debounce is to guard against code pasted into the REPL.
|
|
const kDebounceHistoryMS = 15;
|
|
const kHistorySize = 30;
|
|
|
|
// 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');
|
|
|
|
// 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');
|
|
|
|
class ReplHistory {
|
|
constructor(context, options) {
|
|
this[kValidateOptions](options);
|
|
|
|
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;
|
|
}
|
|
|
|
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',
|
|
);
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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++;
|
|
}
|
|
|
|
this[kIndex] = index;
|
|
|
|
if (index === this[kHistory].length) {
|
|
return search;
|
|
}
|
|
|
|
return ReplHistory[kNormalizeLineEndings](this[kHistory][index], '\r', '\n');
|
|
}
|
|
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
|
|
static [kWriteToOutput](context, message) {
|
|
if (typeof context._writeToOutput === 'function') {
|
|
context._writeToOutput(message);
|
|
if (typeof context._refreshLine === 'function') {
|
|
context._refreshLine();
|
|
}
|
|
}
|
|
}
|
|
|
|
[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];
|
|
}
|
|
|
|
[kHasWritePermission]() {
|
|
return !(permission.isEnabled() &&
|
|
permission.has('fs.write', this[kHistoryPath]) === false);
|
|
}
|
|
|
|
[kValidateOptions](options) {
|
|
if (typeof options.history !== 'undefined') {
|
|
validateArray(options.history, 'history');
|
|
}
|
|
if (typeof options.size !== 'undefined') {
|
|
validateNumber(options.size, 'size', 0);
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
this[kContext].once('flushHistory', () => {
|
|
if (!this[kContext].closed) {
|
|
this[kContext].resume();
|
|
onReadyCallback(null, this[kContext]);
|
|
}
|
|
});
|
|
|
|
await this[kFlushHistory]();
|
|
} catch (err) {
|
|
return this[kHandleHistoryInitError](err, onReadyCallback);
|
|
}
|
|
}
|
|
|
|
[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);
|
|
|
|
// 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]);
|
|
}
|
|
|
|
this[kTimer] = setTimeout(() => this[kFlushHistory](), kDebounceHistoryMS);
|
|
}
|
|
|
|
async [kFlushHistory]() {
|
|
this[kTimer] = null;
|
|
if (this[kWriting]) {
|
|
this[kPending] = true;
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async [kOnExit]() {
|
|
if (this[kIsFlushing]) {
|
|
this[kContext].once('flushHistory', this[kOnExit].bind(this));
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
[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();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ReplHistory,
|
|
};
|