// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and // other Node contributors). See LICENSE-node. // The in-memory half of Node v24's lib/internal/repl/history.js, which readline's // Interface keeps its history in. Persisting history to a file is the REPL's business // and is not here. import { invalidArgType } from "../errors"; import { reverseString } from "./utils"; const kHistorySize = 30; export interface HistoryContext { line: string; emit(event: "history", history: string[]): unknown; } export interface HistoryOptions { size?: number; history?: string[]; removeHistoryDuplicates?: boolean; } /** Multiline entries are stored reversed, newest line first. */ const normalizeLineEndings = (line: string, from: string, to: string): string => reverseString(line, from, to); export class ReplHistory { #context: HistoryContext; #removeHistoryDuplicates: boolean; #size: number; #history: string[]; #index = -1; constructor(context: HistoryContext, options: HistoryOptions) { if (options.history !== undefined && !Array.isArray(options.history)) { throw invalidArgType("history", "an instance of Array", options.history); } if (options.size !== undefined) { if (typeof options.size !== "number") throw invalidArgType("size", "of type number", options.size); if (!(options.size >= 0)) { throw Object.assign(new RangeError(`The value of "size" is out of range. It must be >= 0. Received ${options.size}`), { code: "ERR_OUT_OF_RANGE", }); } } this.#context = context; this.#removeHistoryDuplicates = options.removeHistoryDuplicates || false; this.#size = options.size ?? kHistorySize; this.#history = options.history ?? []; } addHistory(isMultiline: boolean, lastCommandErrored: boolean): string { const line = this.#context.line; if (line.length === 0) return ""; if (this.#size === 0) return line; if (line.trim().length === 0) return line; // Each line of a multiline entry was saved as it was typed; keep only the whole. if ((isMultiline && this.#index === -1) || lastCommandErrored) this.#history.shift(); const normalizedLine = normalizeLineEndings(line, "\n", "\r"); if (this.#history.length === 0 || this.#history[0] !== normalizedLine) { if (this.#removeHistoryDuplicates) { const dupIndex = this.#history.indexOf(line); if (dupIndex !== -1) this.#history.splice(dupIndex, 1); } this.#history.unshift(normalizedLine); if (this.#history.length > this.#size) this.#history.pop(); } this.#index = -1; const finalLine = isMultiline ? reverseString(this.#history[0]!) : this.#history[0]!; // A listener may edit the history, e.g. to drop a password before it is kept. this.#context.emit("history", this.#history); return finalLine; } canNavigateToNext(): boolean { return this.#index > -1 && this.#history.length > 0; } navigateToNext(substringSearch: string | null): string | null { if (!this.canNavigateToNext()) return null; const search = substringSearch || ""; let index = this.#index - 1; while (index >= 0 && (!this.#history[index]!.startsWith(search) || this.#context.line === this.#history[index])) index--; this.#index = index; if (index === -1) return search; return normalizeLineEndings(this.#history[index]!, "\r", "\n"); } canNavigateToPrevious(): boolean { return this.#history.length !== this.#index && this.#history.length > 0; } navigateToPrevious(substringSearch: string | null = ""): string | null { if (!this.canNavigateToPrevious()) return null; const search = substringSearch || ""; let index = this.#index + 1; while (index < this.#history.length && (!this.#history[index]!.startsWith(search) || this.#context.line === this.#history[index])) { index++; } this.#index = index; if (index === this.#history.length) return search; return normalizeLineEndings(this.#history[index]!, "\r", "\n"); } get size(): number { return this.#size; } get isFlushing(): boolean { return false; } get history(): string[] { return this.#history; } set history(value: string[]) { this.#history = value; } get index(): number { return this.#index; } set index(value: number) { this.#index = value; } }