current commit
260823.21 · repl: keep entries added while history file is loading
'use strict';

const {
  ArrayPrototypeIndexOf,
  ArrayPrototypeJoin,
  ArrayPrototypePop,
  ArrayPrototypePushApply,
  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');
const kCloseHandle = Symbol('_kCloseHandle');

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 && this[kIndex] !== -1) {
      // 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], normalizedLine);
        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);
      }

      const loadedHistory = data ?
        RegExpPrototypeSymbolSplit(/\r?\n+/, data, this[kSize]) :
        [];

      // Lines can be evaluated while the history file is still being read,
      // e.g. when the input stream does not support pausing. Such entries
      // are already in the in-memory history (newest first), so append the
      // persisted entries to them instead of discarding them.
      if (this[kHistory].length > 0) {
        ArrayPrototypePushApply(this[kHistory], loadedHistory);
        if (this[kHistory].length > this[kSize]) {
          ArrayPrototypeSplice(this[kHistory], this[kSize]);
        }
      } else {
        this[kHistory] = loadedHistory;
      }

      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) {
      await this[kCloseHandle]();
      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));

    await this[kCloseHandle]();
  }

  async [kCloseHandle]() {
    if (this[kHistoryHandle] !== null) {
      const handle = this[kHistoryHandle];
      this[kHistoryHandle] = null;
      try {
        await handle.close();
      } catch (err) {
        debug('Error closing history file:', err);
      }
    }
  }

  /**
   * Closes the history file handle.
   * @returns {Promise<void>}
   */
  closeHandle() {
    return this[kCloseHandle]();
  }

  [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,
};