From 650be64b6f9e6c8c9aec86444089fdce5147c70d Mon Sep 17 00:00:00 2001 From: Puellaquae Date: Tue, 22 Nov 2022 21:49:56 +0800 Subject: [PATCH 1/2] impl: tty module --- modules/internal/tty.js | 230 +++++++++++++++++++++++++++++++++++++ modules/tty.js | 95 +++++++++++++++ src/internal_module/mod.rs | 1 + src/internal_module/tty.rs | 29 +++++ src/quickjs_sys/mod.rs | 1 + 5 files changed, 356 insertions(+) create mode 100644 modules/internal/tty.js create mode 100644 modules/tty.js create mode 100644 src/internal_module/tty.rs diff --git a/modules/internal/tty.js b/modules/internal/tty.js new file mode 100644 index 0000000..aefbf91 --- /dev/null +++ b/modules/internal/tty.js @@ -0,0 +1,230 @@ +// MIT License + +// Copyright (c) Sindre Sorhus (sindresorhus.com) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +'use strict'; + +const { validateInteger } = require('internal/validators'); + +const COLORS_2 = 1; +const COLORS_16 = 4; +const COLORS_256 = 8; +const COLORS_16m = 24; + +// Some entries were taken from `dircolors` +// (https://linux.die.net/man/1/dircolors). The corresponding terminals might +// support more than 16 colors, but this was not tested for. +// +// Copyright (C) 1996-2016 Free Software Foundation, Inc. Copying and +// distribution of this file, with or without modification, are permitted +// provided the copyright notice and this notice are preserved. +const TERM_ENVS = { + 'eterm': COLORS_16, + 'cons25': COLORS_16, + 'console': COLORS_16, + 'cygwin': COLORS_16, + 'dtterm': COLORS_16, + 'gnome': COLORS_16, + 'hurd': COLORS_16, + 'jfbterm': COLORS_16, + 'konsole': COLORS_16, + 'kterm': COLORS_16, + 'mlterm': COLORS_16, + 'mosh': COLORS_16m, + 'putty': COLORS_16, + 'st': COLORS_16, + // https://github.com/da-x/rxvt-unicode/tree/v9.22-with-24bit-color + 'rxvt-unicode-24bit': COLORS_16m, + // https://gist.github.com/XVilka/8346728#gistcomment-2823421 + 'terminator': COLORS_16m +}; + +const TERM_ENVS_REG_EXP = [ + /ansi/, + /color/, + /linux/, + /^con[0-9]*x[0-9]/, + /^rxvt/, + /^screen/, + /^xterm/, + /^vt100/, +]; + +let warned = false; +function warnOnDeactivatedColors(env) { + if (warned) + return; + let name = ''; + if (env.NODE_DISABLE_COLORS !== undefined) + name = 'NODE_DISABLE_COLORS'; + if (env.NO_COLOR !== undefined) { + if (name !== '') { + name += "' and '"; + } + name += 'NO_COLOR'; + } + + if (name !== '') { + process.emitWarning( + `The '${name}' env is ignored due to the 'FORCE_COLOR' env being set.`, + 'Warning' + ); + warned = true; + } +} + +// The `getColorDepth` API got inspired by multiple sources such as +// https://github.com/chalk/supports-color, +// https://github.com/isaacs/color-support. +function getColorDepth(env = process.env) { + // Use level 0-3 to support the same levels as `chalk` does. This is done for + // consistency throughout the ecosystem. + if (env.FORCE_COLOR !== undefined) { + switch (env.FORCE_COLOR) { + case '': + case '1': + case 'true': + warnOnDeactivatedColors(env); + return COLORS_16; + case '2': + warnOnDeactivatedColors(env); + return COLORS_256; + case '3': + warnOnDeactivatedColors(env); + return COLORS_16m; + default: + return COLORS_2; + } + } + + if (env.NODE_DISABLE_COLORS !== undefined || + // See https://no-color.org/ + env.NO_COLOR !== undefined || + // The "dumb" special terminal, as defined by terminfo, doesn't support + // ANSI color control codes. + // See https://invisible-island.net/ncurses/terminfo.ti.html#toc-_Specials + env.TERM === 'dumb') { + return COLORS_2; + } + + /* + if (process.platform === 'win32') { + // Lazy load for startup performance. + if (OSRelease === undefined) { + const { release } = require('os'); + OSRelease = String.prototype.split(release(), '.'); + } + // Windows 10 build 10586 is the first Windows release that supports 256 + // colors. Windows 10 build 14931 is the first release that supports + // 16m/TrueColor. + if (+OSRelease[0] >= 10) { + const build = +OSRelease[2]; + if (build >= 14931) + return COLORS_16m; + if (build >= 10586) + return COLORS_256; + } + + return COLORS_16; + } + */ + + if (env.TMUX) { + return COLORS_256; + } + + if (env.CI) { + if ([ + 'APPVEYOR', + 'BUILDKITE', + 'CIRCLECI', + 'DRONE', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'TRAVIS', + ].some((sign) => sign in env) || env.CI_NAME === 'codeship') { + return COLORS_256; + } + return COLORS_2; + } + + if ('TEAMCITY_VERSION' in env) { + return RegExp.prototype.exec.call(/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/, env.TEAMCITY_VERSION) !== null ? + COLORS_16 : COLORS_2; + } + + switch (env.TERM_PROGRAM) { + case 'iTerm.app': + if (!env.TERM_PROGRAM_VERSION || + RegExp.prototype.exec.call(/^[0-2]\./, env.TERM_PROGRAM_VERSION) !== null + ) { + return COLORS_256; + } + return COLORS_16m; + case 'HyperTerm': + case 'MacTerm': + return COLORS_16m; + case 'Apple_Terminal': + return COLORS_256; + } + + if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') { + return COLORS_16m; + } + + if (env.TERM) { + if (RegExp.prototype.exec.call(/^xterm-256/, env.TERM) !== null) { + return COLORS_256; + } + + const termEnv = String.prototype.toLowerCase.call(env.TERM); + + if (TERM_ENVS[termEnv]) { + return TERM_ENVS[termEnv]; + } + if (Array.prototype.some.call(TERM_ENVS_REG_EXP, + (term) => RegExp.prototype.exec.call(term, termEnv) !== null)) { + return COLORS_16; + } + } + // Move 16 color COLORTERM below 16m and 256 + if (env.COLORTERM) { + return COLORS_16; + } + return COLORS_2; +} + +function hasColors(count, env) { + if (env === undefined && + (count === undefined || (typeof count === 'object' && count !== null))) { + env = count; + count = 16; + } else { + validateInteger(count, 'count', 2); + } + + return count <= 2 ** getColorDepth(env); +} + +export { + getColorDepth, + hasColors +}; diff --git a/modules/tty.js b/modules/tty.js new file mode 100644 index 0000000..f6b72b2 --- /dev/null +++ b/modules/tty.js @@ -0,0 +1,95 @@ +import internal from "_node:tty" +import fs from "fs" +import { hasColors, getColorDepth } from "./internal/tty"; + +export function isatty(fd) { + return Number.isInteger(fd) && fd >= 0 && fd <= 2147483647 && (internal.isatty(fd) ?? false); +} + +// net.Socket is unimplemented now, use fs.ReadStream temporarily +export class ReadStream extends fs.ReadStream { + constructor(fd) { + super("", { fd }); + } + + get isTTY() { + return true; + } + + get isRaw() { + return false; + } + + setRawMode(mode) { + // require tcgetattr and tcsetattr or ioctl, unsupported + return this; + } +} + +export class WriteStream extends fs.WriteStream { + constructor(fd) { + super("", { fd }); + } + + clearLine(dir, callback) { + if (dir === -1) { + this.write("\x1b[K"); // clear left + } else if (dir === 1) { + this.write("\x1b[1K"); // clear right + } else if (dir === 2) { + this.write("\x1b[2K"); // clear all + } + if (typeof (callback) === "function") { + callback(); + } + return true; + } + + clearScreenDown(callback) { + this.write("\x1b[J"); + if (typeof (callback) === "function") { + callback(); + } + } + + getWindowSize() { + // require tcgetwinsize, unsupported + return [undefined, undefined]; + } + + get columns() { + return this.getWindowSize[0]; + } + + get rows() { + return this.getWindowSize[1]; + } + + getColorDepth = getColorDepth; + + hasColors = hasColors; + + get isTTY() { + return true; + } + + cursorTo(x, y, callback) { + this.write(`\x1b[${x};${y}`); + if (typeof (callback) === "function") { + callback(); + } + return true; + } + + moveCursor(dx, dy, callback) { + this.write((dx > 0 ? `\x1b[${dx}C` : `\x1b[${-dx}D`) + (dy > 0 ? `\x1b[${dy}B` : `\x1b[${-dy}A`)) + if (typeof (callback) === "function") { + callback(); + } + return true; + } +} + +export default { + isatty +} \ No newline at end of file diff --git a/src/internal_module/mod.rs b/src/internal_module/mod.rs index ad26a97..5eadd1c 100644 --- a/src/internal_module/mod.rs +++ b/src/internal_module/mod.rs @@ -7,3 +7,4 @@ pub mod img_module; pub mod tensorflow_module; pub mod wasi_net_module; pub mod os; +pub mod tty; \ No newline at end of file diff --git a/src/internal_module/tty.rs b/src/internal_module/tty.rs new file mode 100644 index 0000000..a48ef1f --- /dev/null +++ b/src/internal_module/tty.rs @@ -0,0 +1,29 @@ +use crate::quickjs_sys::*; +use crate::EventLoop; + +fn isatty(_ctx: &mut Context, _this_val: JsValue, argv: &[JsValue]) -> JsValue { + match argv.get(0) { + Some(JsValue::Int(fd)) => (unsafe { libc::isatty(*fd) } == 1).into(), + Some(JsValue::Float(fd)) => (unsafe { libc::isatty(*fd as i32) } == 1).into(), + _ => JsValue::UnDefined + } +} + +struct TTY; + +impl ModuleInit for TTY { + fn init_module(ctx: &mut Context, m: &mut JsModuleDef) { + let f = ctx.wrap_function("isatty", isatty); + m.add_export("isatty\0", f.into()); + } +} + +pub fn init_module(ctx: &mut Context) { + ctx.register_module( + "_node:tty\0", + TTY, + &[ + "isatty\0" + ] + ) +} \ No newline at end of file diff --git a/src/quickjs_sys/mod.rs b/src/quickjs_sys/mod.rs index 7a281e6..15a0cb3 100644 --- a/src/quickjs_sys/mod.rs +++ b/src/quickjs_sys/mod.rs @@ -343,6 +343,7 @@ impl Context { super::internal_module::wasi_net_module::init_module(&mut ctx); super::internal_module::httpx::init_module(&mut ctx); super::internal_module::os::init_module(&mut ctx); + super::internal_module::tty::init_module(&mut ctx); ctx } From bb35303aaee49103e6d72a8941a51802c1f580a6 Mon Sep 17 00:00:00 2001 From: Puellaquae Date: Mon, 28 Nov 2022 21:16:05 +0800 Subject: [PATCH 2/2] impl readline --- modules/internal/errors.js | 6 + modules/internal/readline/callbacks.js | 129 ++ .../internal/readline/emitKeypressEvents.js | 95 ++ modules/internal/readline/interface.js | 1417 +++++++++++++++++ modules/internal/readline/promises.js | 135 ++ modules/internal/readline/utils.js | 391 +++++ modules/internal/util.js | 3 + modules/readline.js | 517 ++++++ modules/tty.js | 205 ++- 9 files changed, 2825 insertions(+), 73 deletions(-) create mode 100644 modules/internal/readline/callbacks.js create mode 100644 modules/internal/readline/emitKeypressEvents.js create mode 100644 modules/internal/readline/interface.js create mode 100644 modules/internal/readline/promises.js create mode 100644 modules/internal/readline/utils.js create mode 100644 modules/readline.js diff --git a/modules/internal/errors.js b/modules/internal/errors.js index 10c94a8..e667815 100644 --- a/modules/internal/errors.js +++ b/modules/internal/errors.js @@ -236,6 +236,12 @@ export class ERR_STREAM_NULL_VALUES extends TypeError { } } +export class ERR_INVALID_CURSOR_POS extends TypeError { + constructor() { + super("ERR_INVALID_CURSOR_POS", `Cannot set cursor row without setting its column`); + } +} + export class ERR_STREAM_DESTROYED extends Error { constructor(x) { super( diff --git a/modules/internal/readline/callbacks.js b/modules/internal/readline/callbacks.js new file mode 100644 index 0000000..3f464d1 --- /dev/null +++ b/modules/internal/readline/callbacks.js @@ -0,0 +1,129 @@ +// Copyright Joyent and Node contributors. All rights reserved. MIT license. + +'use strict'; + +import { ERR_INVALID_ARG_VALUE, ERR_INVALID_CURSOR_POS } from '../errors'; + +import { validateFunction } from '../validators'; + +import { CSI } from './utils'; + +const { + kClearLine, + kClearScreenDown, + kClearToLineBeginning, + kClearToLineEnd, +} = CSI; + + +/** + * moves the cursor to the x and y coordinate on the given stream + */ + +function cursorTo(stream, x, y, callback) { + if (callback !== undefined) { + validateFunction(callback, 'callback'); + } + + if (typeof y === 'function') { + callback = y; + y = undefined; + } + + if (Number.isNaN(x)) throw new ERR_INVALID_ARG_VALUE('x', x); + if (Number.isNaN(y)) throw new ERR_INVALID_ARG_VALUE('y', y); + + if (stream == null || (typeof x !== 'number' && typeof y !== 'number')) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + if (typeof x !== 'number') throw new ERR_INVALID_CURSOR_POS(); + + const data = typeof y !== 'number' ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; + return stream.write(data, callback); +} + +/** + * moves the cursor relative to its current location + */ + +function moveCursor(stream, dx, dy, callback) { + if (callback !== undefined) { + validateFunction(callback, 'callback'); + } + + if (stream == null || !(dx || dy)) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + let data = ''; + + if (dx < 0) { + data += CSI`${-dx}D`; + } else if (dx > 0) { + data += CSI`${dx}C`; + } + + if (dy < 0) { + data += CSI`${-dy}A`; + } else if (dy > 0) { + data += CSI`${dy}B`; + } + + return stream.write(data, callback); +} + +/** + * clears the current line the cursor is on: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + */ + +function clearLine(stream, dir, callback) { + if (callback !== undefined) { + validateFunction(callback, 'callback'); + } + + if (stream === null || stream === undefined) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + const type = + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine; + return stream.write(type, callback); +} + +/** + * clears the screen from the current position of the cursor down + */ + +function clearScreenDown(stream, callback) { + if (callback !== undefined) { + validateFunction(callback, 'callback'); + } + + if (stream === null || stream === undefined) { + if (typeof callback === 'function') process.nextTick(callback, null); + return true; + } + + return stream.write(kClearScreenDown, callback); +} + +export { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +}; + +export default { + clearLine, + clearScreenDown, + cursorTo, + moveCursor, +}; diff --git a/modules/internal/readline/emitKeypressEvents.js b/modules/internal/readline/emitKeypressEvents.js new file mode 100644 index 0000000..a24b19c --- /dev/null +++ b/modules/internal/readline/emitKeypressEvents.js @@ -0,0 +1,95 @@ +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +'use strict'; + +import{ + charLengthAt, + CSI, + emitKeys, +} from 'internal/readline/utils'; +import { + kSawKeyPress, +} from 'internal/readline/interface'; + + +const { + kEscape, +} = CSI; + +import { StringDecoder } from 'string_decoder'; + +const KEYPRESS_DECODER = Symbol('keypress-decoder'); +const ESCAPE_DECODER = Symbol('escape-decoder'); + +// GNU readline library - keyseq-timeout is 500ms (default) +const ESCAPE_CODE_TIMEOUT = 500; + +/** + * accepts a readable Stream instance and makes it emit "keypress" events + */ + +function emitKeypressEvents(stream, iface = {}) { + if (stream[KEYPRESS_DECODER]) return; + + stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); + + stream[ESCAPE_DECODER] = emitKeys(stream); + stream[ESCAPE_DECODER].next(); + + const triggerEscape = () => stream[ESCAPE_DECODER].next(''); + const { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface; + let timeoutId; + + function onData(input) { + if (stream.listenerCount('keypress') > 0) { + const string = stream[KEYPRESS_DECODER].write(input); + if (string) { + clearTimeout(timeoutId); + + // This supports characters of length 2. + iface[kSawKeyPress] = charLengthAt(string, 0) === string.length; + iface.isCompletionEnabled = false; + + let length = 0; + for (const character of [...string]) { + length += character.length; + if (length === string.length) { + iface.isCompletionEnabled = true; + } + + try { + stream[ESCAPE_DECODER].next(character); + // Escape letter at the tail position + if (length === string.length && character === kEscape) { + timeoutId = setTimeout(triggerEscape, escapeCodeTimeout); + } + } catch (err) { + // If the generator throws (it could happen in the `keypress` + // event), we need to restart it. + stream[ESCAPE_DECODER] = emitKeys(stream); + stream[ESCAPE_DECODER].next(); + throw err; + } + } + } + } else { + // Nobody's watching anyway + stream.removeListener('data', onData); + stream.on('newListener', onNewListener); + } + } + + function onNewListener(event) { + if (event === 'keypress') { + stream.on('data', onData); + stream.removeListener('newListener', onNewListener); + } + } + + if (stream.listenerCount('keypress') > 0) { + stream.on('data', onData); + } else { + stream.on('newListener', onNewListener); + } +} + +export { emitKeypressEvents }; diff --git a/modules/internal/readline/interface.js b/modules/internal/readline/interface.js new file mode 100644 index 0000000..6c8f3e6 --- /dev/null +++ b/modules/internal/readline/interface.js @@ -0,0 +1,1417 @@ +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +'use strict' + +import { ERR_INVALID_ARG_VALUE, ERR_USE_AFTER_CLOSE } from '../errors'; + +import { + validateAbortSignal, + validateArray, + validateString, + validateUint32, +} from '../validators'; +import { kEmptyObject } from '../util'; +import { + inspect, + getStringWidth, + stripVTControlCharacters, +} from 'internal/util/inspect'; +import EventEmitter from 'events'; +import { + charLengthAt, + charLengthLeft, + commonPrefix, + kSubstringSearch, +} from 'internal/readline/utils'; +import { emitKeypressEvents } from './emitKeypressEvents'; +import { + clearScreenDown, + cursorTo, + moveCursor, +} from 'internal/readline/callbacks'; + +import { StringDecoder } from 'string_decoder'; + +// Lazy load Readable for startup performance. +let Readable; + +const kHistorySize = 30; +const kMaxUndoRedoStackSize = 2048; +const kMincrlfDelay = 100; +// \r\n, \n, or \r followed by something other than \n +const lineEnding = /\r?\n|\r(?!\n)/; + +const kLineObjectStream = Symbol('line object stream'); +const kQuestionCancel = Symbol('kQuestionCancel'); + +// GNU readline library - keyseq-timeout is 500ms (default) +const ESCAPE_CODE_TIMEOUT = 500; + +// Max length of the kill ring +const kMaxLengthOfKillRing = 32; + +const kAddHistory = Symbol('_addHistory'); +const kBeforeEdit = Symbol('_beforeEdit'); +const kDecoder = Symbol('_decoder'); +const kDeleteLeft = Symbol('_deleteLeft'); +const kDeleteLineLeft = Symbol('_deleteLineLeft'); +const kDeleteLineRight = Symbol('_deleteLineRight'); +const kDeleteRight = Symbol('_deleteRight'); +const kDeleteWordLeft = Symbol('_deleteWordLeft'); +const kDeleteWordRight = Symbol('_deleteWordRight'); +const kGetDisplayPos = Symbol('_getDisplayPos'); +const kHistoryNext = Symbol('_historyNext'); +const kHistoryPrev = Symbol('_historyPrev'); +const kInsertString = Symbol('_insertString'); +const kLine = Symbol('_line'); +const kLine_buffer = Symbol('_line_buffer'); +const kKillRing = Symbol('_killRing'); +const kKillRingCursor = Symbol('_killRingCursor'); +const kMoveCursor = Symbol('_moveCursor'); +const kNormalWrite = Symbol('_normalWrite'); +const kOldPrompt = Symbol('_oldPrompt'); +const kOnLine = Symbol('_onLine'); +const kPreviousKey = Symbol('_previousKey'); +const kPrompt = Symbol('_prompt'); +const kPushToKillRing = Symbol('_pushToKillRing'); +const kPushToUndoStack = Symbol('_pushToUndoStack'); +const kQuestionCallback = Symbol('_questionCallback'); +const kRedo = Symbol('_redo'); +const kRedoStack = Symbol('_redoStack'); +const kRefreshLine = Symbol('_refreshLine'); +const kSawKeyPress = Symbol('_sawKeyPress'); +const kSawReturnAt = Symbol('_sawReturnAt'); +const kSetRawMode = Symbol('_setRawMode'); +const kTabComplete = Symbol('_tabComplete'); +const kTabCompleter = Symbol('_tabCompleter'); +const kTtyWrite = Symbol('_ttyWrite'); +const kUndo = Symbol('_undo'); +const kUndoStack = Symbol('_undoStack'); +const kWordLeft = Symbol('_wordLeft'); +const kWordRight = Symbol('_wordRight'); +const kWriteToOutput = Symbol('_writeToOutput'); +const kYank = Symbol('_yank'); +const kYanking = Symbol('_yanking'); +const kYankPop = Symbol('_yankPop'); + +function InterfaceConstructor(input, output, completer, terminal) { + this[kSawReturnAt] = 0; + // TODO(BridgeAR): Document this property. The name is not ideal, so we + // might want to expose an alias and document that instead. + this.isCompletionEnabled = true; + this[kSawKeyPress] = false; + this[kPreviousKey] = null; + this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT; + this.tabSize = 8; + + Function.prototype.call.call(EventEmitter, this); + + let history; + let historySize; + let removeHistoryDuplicates = false; + let crlfDelay; + let prompt = '> '; + let signal; + + if (input?.input) { + // An options object was given + output = input.output; + completer = input.completer; + terminal = input.terminal; + history = input.history; + historySize = input.historySize; + signal = input.signal; + if (input.tabSize !== undefined) { + validateUint32(input.tabSize, 'tabSize', true); + this.tabSize = input.tabSize; + } + removeHistoryDuplicates = input.removeHistoryDuplicates; + if (input.prompt !== undefined) { + prompt = input.prompt; + } + if (input.escapeCodeTimeout !== undefined) { + if (Number.isFinite(input.escapeCodeTimeout)) { + this.escapeCodeTimeout = input.escapeCodeTimeout; + } else { + throw new ERR_INVALID_ARG_VALUE( + 'input.escapeCodeTimeout', + this.escapeCodeTimeout + ); + } + } + + if (signal) { + validateAbortSignal(signal, 'options.signal'); + } + + crlfDelay = input.crlfDelay; + input = input.input; + } + + if (completer !== undefined && typeof completer !== 'function') { + throw new ERR_INVALID_ARG_VALUE('completer', completer); + } + + if (history === undefined) { + history = []; + } else { + validateArray(history, 'history'); + } + + if (historySize === undefined) { + historySize = kHistorySize; + } + + if ( + typeof historySize !== 'number' || + Number.isNaN(historySize) || + historySize < 0 + ) { + throw new ERR_INVALID_ARG_VALUE.RangeError('historySize', historySize); + } + + // Backwards compat; check the isTTY prop of the output stream + // when `terminal` was not specified + if (terminal === undefined && !(output === null || output === undefined)) { + terminal = !!output.isTTY; + } + + const self = this; + + this.line = ''; + this[kSubstringSearch] = null; + this.output = output; + this.input = input; + this[kUndoStack] = []; + this[kRedoStack] = []; + this.history = history; + this.historySize = historySize; + + // The kill ring is a global list of blocks of text that were previously + // killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest + // element will be removed to make room for the latest deletion. With kill + // ring, users are able to recall (yank) or cycle (yank pop) among previously + // killed texts, quite similar to the behavior of Emacs. + this[kKillRing] = []; + this[kKillRingCursor] = 0; + + this.removeHistoryDuplicates = !!removeHistoryDuplicates; + this.crlfDelay = crlfDelay ? + Math.max(kMincrlfDelay, crlfDelay) : + kMincrlfDelay; + this.completer = completer; + + this.setPrompt(prompt); + + this.terminal = !!terminal; + + + function onerror(err) { + self.emit('error', err); + } + + function ondata(data) { + self[kNormalWrite](data); + } + + function onend() { + if ( + typeof self[kLine_buffer] === 'string' && + self[kLine_buffer].length > 0 + ) { + self.emit('line', self[kLine_buffer]); + } + self.close(); + } + + function ontermend() { + if (typeof self.line === 'string' && self.line.length > 0) { + self.emit('line', self.line); + } + self.close(); + } + + function onkeypress(s, key) { + self[kTtyWrite](s, key); + if (key && key.sequence) { + // If the key.sequence is half of a surrogate pair + // (>= 0xd800 and <= 0xdfff), refresh the line so + // the character is displayed appropriately. + const ch = String.prototype.codePointAt(key.sequence, 0); + if (ch >= 0xd800 && ch <= 0xdfff) self[kRefreshLine](); + } + } + + function onresize() { + self[kRefreshLine](); + } + + this[kLineObjectStream] = undefined; + + input.on('error', onerror); + + if (!this.terminal) { + function onSelfCloseWithoutTerminal() { + input.removeListener('data', ondata); + input.removeListener('error', onerror); + input.removeListener('end', onend); + } + + input.on('data', ondata); + input.on('end', onend); + self.once('close', onSelfCloseWithoutTerminal); + this[kDecoder] = new StringDecoder('utf8'); + } else { + function onSelfCloseWithTerminal() { + input.removeListener('keypress', onkeypress); + input.removeListener('error', onerror); + input.removeListener('end', ontermend); + if (output !== null && output !== undefined) { + output.removeListener('resize', onresize); + } + } + + emitKeypressEvents(input, this); + + // `input` usually refers to stdin + input.on('keypress', onkeypress); + input.on('end', ontermend); + + this[kSetRawMode](true); + this.terminal = true; + + // Cursor position on the line. + this.cursor = 0; + + this.historyIndex = -1; + + if (output !== null && output !== undefined) + output.on('resize', onresize); + + self.once('close', onSelfCloseWithTerminal); + } + + if (signal) { + const onAborted = () => self.close(); + if (signal.aborted) { + process.nextTick(onAborted); + } else { + signal.addEventListener('abort', onAborted, { once: true }); + self.once('close', () => signal.removeEventListener('abort', onAborted)); + } + } + + // Current line + this.line = ''; + + input.resume(); +} + +Object.setPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype); +Object.setPrototypeOf(InterfaceConstructor, EventEmitter); + +class Interface extends InterfaceConstructor { + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + get columns() { + if (this.output && this.output.columns) return this.output.columns; + return Infinity; + } + + /** + * Sets the prompt written to the output. + * @param {string} prompt + * @returns {void} + */ + setPrompt(prompt) { + this[kPrompt] = prompt; + } + + /** + * Returns the current prompt used by `rl.prompt()`. + * @returns {string} + */ + getPrompt() { + return this[kPrompt]; + } + + [kSetRawMode](mode) { + const wasInRawMode = this.input.isRaw; + + if (typeof this.input.setRawMode === 'function') { + this.input.setRawMode(mode); + } + + return wasInRawMode; + } + + /** + * Writes the configured `prompt` to a new line in `output`. + * @param {boolean} [preserveCursor] + * @returns {void} + */ + prompt(preserveCursor) { + if (this.paused) this.resume(); + if (this.terminal && process.env.TERM !== 'dumb') { + if (!preserveCursor) this.cursor = 0; + this[kRefreshLine](); + } else { + this[kWriteToOutput](this[kPrompt]); + } + } + + question(query, cb) { + if (this.closed) { + throw new ERR_USE_AFTER_CLOSE('readline'); + } + if (this[kQuestionCallback]) { + this.prompt(); + } else { + this[kOldPrompt] = this[kPrompt]; + this.setPrompt(query); + this[kQuestionCallback] = cb; + this.prompt(); + } + } + + [kOnLine](line) { + if (this[kQuestionCallback]) { + const cb = this[kQuestionCallback]; + this[kQuestionCallback] = null; + this.setPrompt(this[kOldPrompt]); + cb(line); + } else { + this.emit('line', line); + } + } + + [kBeforeEdit](oldText, oldCursor) { + this[kPushToUndoStack](oldText, oldCursor); + } + + [kQuestionCancel]() { + if (this[kQuestionCallback]) { + this[kQuestionCallback] = null; + this.setPrompt(this[kOldPrompt]); + this.clearLine(); + } + } + + [kWriteToOutput](stringToWrite) { + validateString(stringToWrite, 'stringToWrite'); + + if (this.output !== null && this.output !== undefined) { + this.output.write(stringToWrite); + } + } + + [kAddHistory]() { + if (this.line.length === 0) return ''; + + // If the history is disabled then return the line + if (this.historySize === 0) return this.line; + + // If the trimmed line is empty then return the line + if (String.prototype.trim.call(this.line).length === 0) return this.line; + + if (this.history.length === 0 || this.history[0] !== this.line) { + if (this.removeHistoryDuplicates) { + // Remove older history line if identical to new one + const dupIndex = Array.prototype.indexOf.call(this.history, this.line); + if (dupIndex !== -1) Array.prototype.splice.call(this.history, dupIndex, 1); + } + + Array.prototype.unshift.call(this.history, this.line); + + // Only store so many + if (this.history.length > this.historySize) + Array.prototype.pop.call(this.history); + } + + this.historyIndex = -1; + + // 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 + const line = this.history[0]; + + // Emit history event to notify listeners of update + this.emit('history', this.history); + + return line; + } + + [kRefreshLine]() { + // line length + const line = this[kPrompt] + this.line; + const dispPos = this[kGetDisplayPos](line); + const lineCols = dispPos.cols; + const lineRows = dispPos.rows; + + // cursor position + const cursorPos = this.getCursorPos(); + + // First move to the bottom of the current line, based on cursor pos + const prevRows = this.prevRows || 0; + if (prevRows > 0) { + moveCursor(this.output, 0, -prevRows); + } + + // Cursor to left edge. + cursorTo(this.output, 0); + // erase data + clearScreenDown(this.output); + + // Write the prompt and the current buffer content. + this[kWriteToOutput](line); + + // Force terminal to allocate a new line + if (lineCols === 0) { + this[kWriteToOutput](' '); + } + + // Move cursor to original position. + cursorTo(this.output, cursorPos.cols); + + const diff = lineRows - cursorPos.rows; + if (diff > 0) { + moveCursor(this.output, 0, -diff); + } + + this.prevRows = cursorPos.rows; + } + + /** + * Closes the `readline.Interface` instance. + * @returns {void} + */ + close() { + if (this.closed) return; + this.pause(); + if (this.terminal) { + this[kSetRawMode](false); + } + this.closed = true; + this.emit('close'); + } + + /** + * Pauses the `input` stream. + * @returns {void | Interface} + */ + pause() { + if (this.paused) return; + this.input.pause(); + this.paused = true; + this.emit('pause'); + return this; + } + + /** + * Resumes the `input` stream if paused. + * @returns {void | Interface} + */ + resume() { + if (!this.paused) return; + this.input.resume(); + this.paused = false; + this.emit('resume'); + return this; + } + + /** + * Writes either `data` or a `key` sequence identified by + * `key` to the `output`. + * @param {string} d + * @param {{ + * ctrl?: boolean; + * meta?: boolean; + * shift?: boolean; + * name?: string; + * }} [key] + * @returns {void} + */ + write(d, key) { + if (this.paused) this.resume(); + if (this.terminal) { + this[kTtyWrite](d, key); + } else { + this[kNormalWrite](d); + } + } + + [kNormalWrite](b) { + if (b === undefined) { + return; + } + let string = this[kDecoder].write(b); + if ( + this[kSawReturnAt] && + Date.now() - this[kSawReturnAt] <= this.crlfDelay + ) { + string = RegExp.prototype[Symbol.replace].call(/^\n/, string, ''); + this[kSawReturnAt] = 0; + } + + // Run test() on the new string chunk, not on the entire line buffer. + const newPartContainsEnding = RegExp.prototype.exec.call(lineEnding, string) !== null; + + if (this[kLine_buffer]) { + string = this[kLine_buffer] + string; + this[kLine_buffer] = null; + } + if (newPartContainsEnding) { + this[kSawReturnAt] = String.prototype.endsWith.call(string, '\r') ? + Date.now() : + 0; + + // Got one or more newlines; process into "line" events + const lines = String.prototypes.plit.call(string, lineEnding); + // Either '' or (conceivably) the unfinished portion of the next line + string = Array.prototype.pop.call(lines); + this[kLine_buffer] = string; + for (let n = 0; n < lines.length; n++) this[kOnLine](lines[n]); + } else if (string) { + // No newlines this time, save what we have for next time + this[kLine_buffer] = string; + } + } + + [kInsertString](c) { + this[kBeforeEdit](this.line, this.cursor); + if (this.cursor < this.line.length) { + const beg = String.prototype.slice.call(this.line, 0, this.cursor); + const end = String.prototype.slice.call( + this.line, + this.cursor, + this.line.length + ); + this.line = beg + c + end; + this.cursor += c.length; + this[kRefreshLine](); + } else { + const oldPos = this.getCursorPos(); + this.line += c; + this.cursor += c.length; + const newPos = this.getCursorPos(); + + if (oldPos.rows < newPos.rows) { + this[kRefreshLine](); + } else { + this[kWriteToOutput](c); + } + } + } + + async [kTabComplete](lastKeypressWasTab) { + this.pause(); + const string = String.prototype.slice.call(this.line, 0, this.cursor); + let value; + try { + value = await this.completer(string); + } catch (err) { + this[kWriteToOutput](`Tab completion error: ${inspect(err)}`); + return; + } finally { + this.resume(); + } + this[kTabCompleter](lastKeypressWasTab, value); + } + + [kTabCompleter](lastKeypressWasTab, { 0: completions, 1: completeOn }) { + // Result and the text that was completed. + + if (!completions || completions.length === 0) { + return; + } + + // If there is a common prefix to all matches, then apply that portion. + const prefix = commonPrefix( + Array.prototype.filter.call(completions, (e) => e !== '') + ); + if (String.prototype.startsWith.call(prefix, completeOn) && + prefix.length > completeOn.length) { + this[kInsertString](String.prototype.slice.call(prefix, completeOn.length)); + return; + } else if (!String.prototype.startsWith.call(completeOn, prefix)) { + this.line = String.prototype.slice.call(this.line, + 0, + this.cursor - completeOn.length) + + prefix + + String.prototype.slice.call(this.line, + this.cursor, + this.line.length); + this.cursor = this.cursor - completeOn.length + prefix.length; + this._refreshLine(); + return; + } + + if (!lastKeypressWasTab) { + return; + } + + this[kBeforeEdit](this.line, this.cursor); + + // Apply/show completions. + const completionsWidth = Array.prototype.map.call(completions, (e) => + getStringWidth(e) + ); + const width = Math.max.apply(completionsWidth) + 2; // 2 space padding + let maxColumns = Math.floor(this.columns / width) || 1; + if (maxColumns === Infinity) { + maxColumns = 1; + } + let output = '\r\n'; + let lineIndex = 0; + let whitespace = 0; + for (let i = 0; i < completions.length; i++) { + const completion = completions[i]; + if (completion === '' || lineIndex === maxColumns) { + output += '\r\n'; + lineIndex = 0; + whitespace = 0; + } else { + output += String.prototype.repeat.call(' ', whitespace); + } + if (completion !== '') { + output += completion; + whitespace = width - completionsWidth[i]; + lineIndex++; + } else { + output += '\r\n'; + } + } + if (lineIndex !== 0) { + output += '\r\n\r\n'; + } + this[kWriteToOutput](output); + this[kRefreshLine](); + } + + [kWordLeft]() { + if (this.cursor > 0) { + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + const leading = String.prototype.slice.call(this.line, 0, this.cursor); + const reversed = Array.prototype.join.call( + Array.prototype.reverse.call(Array.from(leading)), + '' + ); + const match = RegExp.prototype.exec.call(/^\s*(?:[^\w\s]+|\w+)?/, reversed); + this[kMoveCursor](-match[0].length); + } + } + + [kWordRight]() { + if (this.cursor < this.line.length) { + const trailing = String.prototype.slice.call(this.line, this.cursor); + const match = RegExp.prototype.exec.call(/^(?:\s+|[^\w\s]+|\w+)\s*/, trailing); + this[kMoveCursor](match[0].length); + } + } + + [kDeleteLeft]() { + if (this.cursor > 0 && this.line.length > 0) { + this[kBeforeEdit](this.line, this.cursor); + // The number of UTF-16 units comprising the character to the left + const charSize = charLengthLeft(this.line, this.cursor); + this.line = + String.prototype.slice.call(this.line, 0, this.cursor - charSize) + + String.prototype.slice.call(this.line, this.cursor, this.line.length); + + this.cursor -= charSize; + this[kRefreshLine](); + } + } + + [kDeleteRight]() { + if (this.cursor < this.line.length) { + this[kBeforeEdit](this.line, this.cursor); + // The number of UTF-16 units comprising the character to the left + const charSize = charLengthAt(this.line, this.cursor); + this.line = + String.prototype.slice.call(this.line, 0, this.cursor) + + String.prototype.slice.call( + this.line, + this.cursor + charSize, + this.line.length + ); + this[kRefreshLine](); + } + } + + [kDeleteWordLeft]() { + if (this.cursor > 0) { + this[kBeforeEdit](this.line, this.cursor); + // Reverse the string and match a word near beginning + // to avoid quadratic time complexity + let leading = String.prototype.slice.call(this.line, 0, this.cursor); + const reversed = Array.prototype.join.call( + Array.prototype.reverse.call(ArrayFrom(leading)), + '' + ); + const match = RegExp.prototype.exec.call(/^\s*(?:[^\w\s]+|\w+)?/, reversed); + leading = String.prototype.slice.call( + leading, + 0, + leading.length - match[0].length + ); + this.line = + leading + + String.prototype.slice.call(this.line, this.cursor, this.line.length); + this.cursor = leading.length; + this[kRefreshLine](); + } + } + + [kDeleteWordRight]() { + if (this.cursor < this.line.length) { + this[kBeforeEdit](this.line, this.cursor); + const trailing = String.prototype.slice.call(this.line, this.cursor); + const match = RegExp.prototype.exec.call(/^(?:\s+|\W+|\w+)\s*/, trailing); + this.line = + String.prototype.slice.call(this.line, 0, this.cursor) + + String.prototype.slice.call(trailing, match[0].length); + this[kRefreshLine](); + } + } + + [kDeleteLineLeft]() { + this[kBeforeEdit](this.line, this.cursor); + const del = String.prototype.slice.call(this.line, 0, this.cursor); + this.line = String.prototype.slice.call(this.line, this.cursor); + this.cursor = 0; + this[kPushToKillRing](del); + this[kRefreshLine](); + } + + [kDeleteLineRight]() { + this[kBeforeEdit](this.line, this.cursor); + const del = String.prototype.slice.call(this.line, this.cursor); + this.line = String.prototype.slice.call(this.line, 0, this.cursor); + this[kPushToKillRing](del); + this[kRefreshLine](); + } + + [kPushToKillRing](del) { + if (!del || del === this[kKillRing][0]) return; + Array.prototype.unshift.call(this[kKillRing], del); + this[kKillRingCursor] = 0; + while (this[kKillRing].length > kMaxLengthOfKillRing) + Array.prototype.pop.call(this[kKillRing]); + } + + [kYank]() { + if (this[kKillRing].length > 0) { + this[kYanking] = true; + this[kInsertString](this[kKillRing][this[kKillRingCursor]]); + } + } + + [kYankPop]() { + if (!this[kYanking]) { + return; + } + if (this[kKillRing].length > 1) { + const lastYank = this[kKillRing][this[kKillRingCursor]]; + this[kKillRingCursor]++; + if (this[kKillRingCursor] >= this[kKillRing].length) { + this[kKillRingCursor] = 0; + } + const currentYank = this[kKillRing][this[kKillRingCursor]]; + const head = + String.prototype.slice.call(this.line, 0, this.cursor - lastYank.length); + const tail = + String.prototype.slice.call(this.line, this.cursor); + this.line = head + currentYank + tail; + this.cursor = head.length + currentYank.length; + this[kRefreshLine](); + } + } + + clearLine() { + this[kMoveCursor](+Infinity); + this[kWriteToOutput]('\r\n'); + this.line = ''; + this.cursor = 0; + this.prevRows = 0; + } + + [kLine]() { + const line = this[kAddHistory](); + this[kUndoStack] = []; + this[kRedoStack] = []; + this.clearLine(); + this[kOnLine](line); + } + + [kPushToUndoStack](text, cursor) { + if (Array.prototype.push.call(this[kUndoStack], { text, cursor }) > + kMaxUndoRedoStackSize) { + Array.prototype.shift.call(this[kUndoStack]); + } + } + + [kUndo]() { + if (this[kUndoStack].length <= 0) return; + + Array.prototype.push.call( + this[kRedoStack], + { text: this.line, cursor: this.cursor }, + ); + + const entry = Array.prototype.pop.call(this[kUndoStack]); + this.line = entry.text; + this.cursor = entry.cursor; + + this[kRefreshLine](); + } + + [kRedo]() { + if (this[kRedoStack].length <= 0) return; + + Array.prototype.push.call( + this[kUndoStack], + { text: this.line, cursor: this.cursor }, + ); + + const entry = Array.prototype.pop.call(this[kRedoStack]); + this.line = entry.text; + this.cursor = entry.cursor; + + this[kRefreshLine](); + } + + // TODO(BridgeAR): Add underscores to the search part and a red background in + // case no match is found. This should only be the visual part and not the + // actual line content! + // TODO(BridgeAR): In case the substring based search is active and the end is + // reached, show a comment how to search the history as before. E.g., using + // + N. Only show this after two/three UPs or DOWNs, not on the first + // one. + [kHistoryNext]() { + if (this.historyIndex >= 0) { + this[kBeforeEdit](this.line, this.cursor); + const search = this[kSubstringSearch] || ''; + let index = this.historyIndex - 1; + while ( + index >= 0 && + (!String.prototype.startsWith.call(this.history[index], search) || + this.line === this.history[index]) + ) { + index--; + } + if (index === -1) { + this.line = search; + } else { + this.line = this.history[index]; + } + this.historyIndex = index; + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); + } + } + + [kHistoryPrev]() { + if (this.historyIndex < this.history.length && this.history.length) { + this[kBeforeEdit](this.line, this.cursor); + const search = this[kSubstringSearch] || ''; + let index = this.historyIndex + 1; + while ( + index < this.history.length && + (!String.prototype.startsWith.call(this.history[index], search) || + this.line === this.history[index]) + ) { + index++; + } + if (index === this.history.length) { + this.line = search; + } else { + this.line = this.history[index]; + } + this.historyIndex = index; + this.cursor = this.line.length; // Set cursor to end of line. + this[kRefreshLine](); + } + } + + // Returns the last character's display position of the given string + [kGetDisplayPos](str) { + let offset = 0; + const col = this.columns; + let rows = 0; + str = stripVTControlCharacters(str); + for (const char of new [...str]) { + if (char === '\n') { + // Rows must be incremented by 1 even if offset = 0 or col = +Infinity. + rows += Math.ceil(offset / col) || 1; + offset = 0; + continue; + } + // Tabs must be aligned by an offset of the tab size. + if (char === '\t') { + offset += this.tabSize - (offset % this.tabSize); + continue; + } + const width = getStringWidth(char, false /* stripVTControlCharacters */); + if (width === 0 || width === 1) { + offset += width; + } else { + // width === 2 + if ((offset + 1) % col === 0) { + offset++; + } + offset += 2; + } + } + const cols = offset % col; + rows += (offset - cols) / col; + return { cols, rows }; + } + + /** + * Returns the real position of the cursor in relation + * to the input prompt + string. + * @returns {{ + * rows: number; + * cols: number; + * }} + */ + getCursorPos() { + const strBeforeCursor = + this[kPrompt] + String.prototype.slice.call(this.line, 0, this.cursor); + return this[kGetDisplayPos](strBeforeCursor); + } + + // This function moves cursor dx places to the right + // (-dx for left) and refreshes the line if it is needed. + [kMoveCursor](dx) { + if (dx === 0) { + return; + } + const oldPos = this.getCursorPos(); + this.cursor += dx; + + // Bounds check + if (this.cursor < 0) { + this.cursor = 0; + } else if (this.cursor > this.line.length) { + this.cursor = this.line.length; + } + + const newPos = this.getCursorPos(); + + // Check if cursor stayed on the line. + if (oldPos.rows === newPos.rows) { + const diffWidth = newPos.cols - oldPos.cols; + moveCursor(this.output, diffWidth, 0); + } else { + this[kRefreshLine](); + } + } + + // Handle a write from the tty + [kTtyWrite](s, key) { + const previousKey = this[kPreviousKey]; + key = key || kEmptyObject; + this[kPreviousKey] = key; + + if (!key.meta || key.name !== 'y') { + // Reset yanking state unless we are doing yank pop. + this[kYanking] = false; + } + + // Activate or deactivate substring search. + if ( + (key.name === 'up' || key.name === 'down') && + !key.ctrl && + !key.meta && + !key.shift + ) { + if (this[kSubstringSearch] === null) { + this[kSubstringSearch] = String.prototype.slice.call( + this.line, + 0, + this.cursor + ); + } + } else if (this[kSubstringSearch] !== null) { + this[kSubstringSearch] = null; + // Reset the index in case there's no match. + if (this.history.length === this.historyIndex) { + this.historyIndex = -1; + } + } + + // Undo & Redo + if (typeof key.sequence === 'string') { + switch (String.prototype.codePointAt.call(key.sequence, 0)) { + case 0x1f: + this[kUndo](); + return; + case 0x1e: + this[kRedo](); + return; + default: + break; + } + } + + // Ignore escape key, fixes + // https://github.com/nodejs/node-v0.x-archive/issues/2876. + if (key.name === 'escape') return; + + if (key.ctrl && key.shift) { + /* Control and shift pressed */ + switch (key.name) { + // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is + // identical to -h. It should have a unique escape sequence. + case 'backspace': + this[kDeleteLineLeft](); + break; + + case 'delete': + this[kDeleteLineRight](); + break; + } + } else if (key.ctrl) { + /* Control key pressed */ + + switch (key.name) { + case 'c': + if (this.listenerCount('SIGINT') > 0) { + this.emit('SIGINT'); + } else { + // This readline instance is finished + this.close(); + } + break; + + case 'h': // delete left + this[kDeleteLeft](); + break; + + case 'd': // delete right or EOF + if (this.cursor === 0 && this.line.length === 0) { + // This readline instance is finished + this.close(); + } else if (this.cursor < this.line.length) { + this[kDeleteRight](); + } + break; + + case 'u': // Delete from current to start of line + this[kDeleteLineLeft](); + break; + + case 'k': // Delete from current to end of line + this[kDeleteLineRight](); + break; + + case 'a': // Go to the start of the line + this[kMoveCursor](-Infinity); + break; + + case 'e': // Go to the end of the line + this[kMoveCursor](+Infinity); + break; + + case 'b': // back one character + this[kMoveCursor](-charLengthLeft(this.line, this.cursor)); + break; + + case 'f': // Forward one character + this[kMoveCursor](+charLengthAt(this.line, this.cursor)); + break; + + case 'l': // Clear the whole screen + cursorTo(this.output, 0, 0); + clearScreenDown(this.output); + this[kRefreshLine](); + break; + + case 'n': // next history item + this[kHistoryNext](); + break; + + case 'p': // Previous history item + this[kHistoryPrev](); + break; + + case 'y': // Yank killed string + this[kYank](); + break; + + case 'z': + if (process.platform === 'win32') break; + if (this.listenerCount('SIGTSTP') > 0) { + this.emit('SIGTSTP'); + } else { + process.once('SIGCONT', () => { + // Don't raise events if stream has already been abandoned. + if (!this.paused) { + // Stream must be paused and resumed after SIGCONT to catch + // SIGINT, SIGTSTP, and EOF. + this.pause(); + this.emit('SIGCONT'); + } + // Explicitly re-enable "raw mode" and move the cursor to + // the correct position. + // See https://github.com/joyent/node/issues/3295. + this[kSetRawMode](true); + this[kRefreshLine](); + }); + this[kSetRawMode](false); + process.kill(process.pid, 'SIGTSTP'); + } + break; + + case 'w': // Delete backwards to a word boundary + // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is + // identical to -h. It should have a unique escape sequence. + // Falls through + case 'backspace': + this[kDeleteWordLeft](); + break; + + case 'delete': // Delete forward to a word boundary + this[kDeleteWordRight](); + break; + + case 'left': + this[kWordLeft](); + break; + + case 'right': + this[kWordRight](); + break; + } + } else if (key.meta) { + /* Meta key pressed */ + + switch (key.name) { + case 'b': // backward word + this[kWordLeft](); + break; + + case 'f': // forward word + this[kWordRight](); + break; + + case 'd': // delete forward word + case 'delete': + this[kDeleteWordRight](); + break; + + case 'backspace': // Delete backwards to a word boundary + this[kDeleteWordLeft](); + break; + + case 'y': // Doing yank pop + this[kYankPop](); + break; + } + } else { + /* No modifier keys used */ + + // \r bookkeeping is only relevant if a \n comes right after. + if (this[kSawReturnAt] && key.name !== 'enter') this[kSawReturnAt] = 0; + + switch (key.name) { + case 'return': // Carriage return, i.e. \r + this[kSawReturnAt] = Date.now(); + this[kLine](); + break; + + case 'enter': + // When key interval > crlfDelay + if ( + this[kSawReturnAt] === 0 || + Date.now() - this[kSawReturnAt] > this.crlfDelay + ) { + this[kLine](); + } + this[kSawReturnAt] = 0; + break; + + case 'backspace': + this[kDeleteLeft](); + break; + + case 'delete': + this[kDeleteRight](); + break; + + case 'left': + // Obtain the code point to the left + this[kMoveCursor](-charLengthLeft(this.line, this.cursor)); + break; + + case 'right': + this[kMoveCursor](+charLengthAt(this.line, this.cursor)); + break; + + case 'home': + this[kMoveCursor](-Infinity); + break; + + case 'end': + this[kMoveCursor](+Infinity); + break; + + case 'up': + this[kHistoryPrev](); + break; + + case 'down': + this[kHistoryNext](); + break; + + case 'tab': + // If tab completion enabled, do that... + if ( + typeof this.completer === 'function' && + this.isCompletionEnabled + ) { + const lastKeypressWasTab = + previousKey && previousKey.name === 'tab'; + this[kTabComplete](lastKeypressWasTab); + break; + } + // falls through + default: + if (typeof s === 'string' && s) { + const lines = RegExp.prototype[Symbol.split].call(/\r\n|\n|\r/, s); + for (let i = 0, len = lines.length; i < len; i++) { + if (i > 0) { + this[kLine](); + } + this[kInsertString](lines[i]); + } + } + } + } + } + + /** + * Creates an `AsyncIterator` object that iterates through + * each line in the input stream as a string. + * @typedef {{ + * [Symbol.asyncIterator]: () => InterfaceAsyncIterator, + * next: () => Promise + * }} InterfaceAsyncIterator + * @returns {InterfaceAsyncIterator} + */ + [SymbolAsyncIterator]() { + if (this[kLineObjectStream] === undefined) { + if (Readable === undefined) { + Readable = require('stream').Readable; + } + const readable = new Readable({ + objectMode: true, + read: () => { + this.resume(); + }, + destroy: (err, cb) => { + this.off('line', lineListener); + this.off('close', closeListener); + this.close(); + cb(err); + }, + }); + const lineListener = (input) => { + if (!readable.push(input)) { + // TODO(rexagod): drain to resume flow + this.pause(); + } + }; + const closeListener = () => { + readable.push(null); + }; + const errorListener = (err) => { + readable.destroy(err); + }; + this.on('error', errorListener); + this.on('line', lineListener); + this.on('close', closeListener); + this[kLineObjectStream] = readable; + } + + return this[kLineObjectStream][SymbolAsyncIterator](); + } +} + +export { + Interface, + InterfaceConstructor, + kAddHistory, + kDecoder, + kDeleteLeft, + kDeleteLineLeft, + kDeleteLineRight, + kDeleteRight, + kDeleteWordLeft, + kDeleteWordRight, + kGetDisplayPos, + kHistoryNext, + kHistoryPrev, + kInsertString, + kLine, + kLine_buffer, + kMoveCursor, + kNormalWrite, + kOldPrompt, + kOnLine, + kPreviousKey, + kPrompt, + kQuestionCallback, + kQuestionCancel, + kRefreshLine, + kSawKeyPress, + kSawReturnAt, + kSetRawMode, + kTabComplete, + kTabCompleter, + kTtyWrite, + kWordLeft, + kWordRight, + kWriteToOutput, +}; + +export default { + Interface, + InterfaceConstructor, + kAddHistory, + kDecoder, + kDeleteLeft, + kDeleteLineLeft, + kDeleteLineRight, + kDeleteRight, + kDeleteWordLeft, + kDeleteWordRight, + kGetDisplayPos, + kHistoryNext, + kHistoryPrev, + kInsertString, + kLine, + kLine_buffer, + kMoveCursor, + kNormalWrite, + kOldPrompt, + kOnLine, + kPreviousKey, + kPrompt, + kQuestionCallback, + kQuestionCancel, + kRefreshLine, + kSawKeyPress, + kSawReturnAt, + kSetRawMode, + kTabComplete, + kTabCompleter, + kTtyWrite, + kWordLeft, + kWordRight, + kWriteToOutput, +}; diff --git a/modules/internal/readline/promises.js b/modules/internal/readline/promises.js new file mode 100644 index 0000000..4a89eb6 --- /dev/null +++ b/modules/internal/readline/promises.js @@ -0,0 +1,135 @@ +// Copyright Joyent and Node contributors. All rights reserved. MIT license. +'use strict'; + +import { CSI } from 'internal/readline/utils'; +import { validateBoolean, validateInteger } from 'internal/validators'; +import { isWritable } from 'internal/streams/utils'; +import { ERR_INVALID_ARG_TYPE } from '../errors'; + +const { + kClearToLineBeginning, + kClearToLineEnd, + kClearLine, + kClearScreenDown, +} = CSI; + +class Readline { + #autoCommit = false; + #stream; + #todo = []; + + constructor(stream, options = undefined) { + if (!isWritable(stream)) + throw new ERR_INVALID_ARG_TYPE('stream', 'Writable', stream); + this.#stream = stream; + if (options?.autoCommit != null) { + validateBoolean(options.autoCommit, 'options.autoCommit'); + this.#autoCommit = options.autoCommit; + } + } + + /** + * Moves the cursor to the x and y coordinate on the given stream. + * @param {integer} x + * @param {integer} [y] + * @returns {Readline} this + */ + cursorTo(x, y = undefined) { + validateInteger(x, 'x'); + if (y != null) validateInteger(y, 'y'); + + const data = y == null ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; + if (this.#autoCommit) process.nextTick(() => this.#stream.write(data)); + else Array.prototype.push.call(this.#todo, data); + + return this; + } + + /** + * Moves the cursor relative to its current location. + * @param {integer} dx + * @param {integer} dy + * @returns {Readline} this + */ + moveCursor(dx, dy) { + if (dx || dy) { + validateInteger(dx, 'dx'); + validateInteger(dy, 'dy'); + + let data = ''; + + if (dx < 0) { + data += CSI`${-dx}D`; + } else if (dx > 0) { + data += CSI`${dx}C`; + } + + if (dy < 0) { + data += CSI`${-dy}A`; + } else if (dy > 0) { + data += CSI`${dy}B`; + } + if (this.#autoCommit) process.nextTick(() => this.#stream.write(data)); + else Array.prototype.push.call(this.#todo, data); + } + return this; + } + + /** + * Clears the current line the cursor is on. + * @param {-1|0|1} dir Direction to clear: + * -1 for left of the cursor + * +1 for right of the cursor + * 0 for the entire line + * @returns {Readline} this + */ + clearLine(dir) { + validateInteger(dir, 'dir', -1, 1); + + const data = + dir < 0 ? kClearToLineBeginning : + dir > 0 ? kClearToLineEnd : + kClearLine; + if (this.#autoCommit) process.nextTick(() => this.#stream.write(data)); + else Array.prototype.push.call(this.#todo, data); + return this; + } + + /** + * Clears the screen from the current position of the cursor down. + * @returns {Readline} this + */ + clearScreenDown() { + if (this.#autoCommit) { + process.nextTick(() => this.#stream.write(kClearScreenDown)); + } else { + Array.prototype.push.call(this.#todo, kClearScreenDown); + } + return this; + } + + /** + * Sends all the pending actions to the associated `stream` and clears the + * internal list of pending actions. + * @returns {Promise} Resolves when all pending actions have been + * flushed to the associated `stream`. + */ + commit() { + return new Promise((resolve) => { + this.#stream.write(Array.prototype.join.call(this.#todo, ''), resolve); + this.#todo = []; + }); + } + + /** + * Clears the internal list of pending actions without sending it to the + * associated `stream`. + * @returns {Readline} this + */ + rollback() { + this.#todo = []; + return this; + } +} + +export default Readline; diff --git a/modules/internal/readline/utils.js b/modules/internal/readline/utils.js new file mode 100644 index 0000000..1eec34b --- /dev/null +++ b/modules/internal/readline/utils.js @@ -0,0 +1,391 @@ +// Copyright Joyent and Node contributors. All rights reserved. MIT license. + +'use strict'; + +const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 +const kEscape = '\x1b'; +const kSubstringSearch = Symbol('kSubstringSearch'); + +function CSI(strings, ...args) { + let ret = `${kEscape}[`; + for (let n = 0; n < strings.length; n++) { + ret += strings[n]; + if (n < args.length) + ret += args[n]; + } + return ret; +} + +CSI.kEscape = kEscape; +CSI.kClearToLineBeginning = CSI`1K`; +CSI.kClearToLineEnd = CSI`0K`; +CSI.kClearLine = CSI`2K`; +CSI.kClearScreenDown = CSI`0J`; + +// TODO(BridgeAR): Treat combined characters as single character, i.e, +// 'a\u0301' and '\u0301a' (both have the same visual output). +// Check Canonical_Combining_Class in +// http://userguide.icu-project.org/strings/properties +function charLengthLeft(str, i) { + if (i <= 0) + return 0; + if ((i > 1 && + String.prototype.codePointAt.call(str, i - 2) >= kUTF16SurrogateThreshold) || + String.prototype.codePointAt.call(str, i - 1) >= kUTF16SurrogateThreshold) { + return 2; + } + return 1; +} + +function charLengthAt(str, i) { + if (str.length <= i) { + // Pretend to move to the right. This is necessary to autocomplete while + // moving to the right. + return 1; + } + return String.prototype.codePointAt.call(str, i) >= kUTF16SurrogateThreshold ? 2 : 1; +} + +/* + Some patterns seen in terminal key escape codes, derived from combos seen + at http://www.midnight-commander.org/browser/lib/tty/key.c + + ESC letter + ESC [ letter + ESC [ modifier letter + ESC [ 1 ; modifier letter + ESC [ num char + ESC [ num ; modifier char + ESC O letter + ESC O modifier letter + ESC O 1 ; modifier letter + ESC N letter + ESC [ [ num ; modifier char + ESC [ [ 1 ; modifier letter + ESC ESC [ num char + ESC ESC O letter + + - char is usually ~ but $ and ^ also happen with rxvt + - modifier is 1 + + (shift * 1) + + (left_alt * 2) + + (ctrl * 4) + + (right_alt * 8) + - two leading ESCs apparently mean the same as one leading ESC +*/ +function* emitKeys(stream) { + while (true) { + let ch = yield; + let s = ch; + let escaped = false; + const key = { + sequence: null, + name: undefined, + ctrl: false, + meta: false, + shift: false + }; + + if (ch === kEscape) { + escaped = true; + s += (ch = yield); + + if (ch === kEscape) { + s += (ch = yield); + } + } + + if (escaped && (ch === 'O' || ch === '[')) { + // ANSI escape sequence + let code = ch; + let modifier = 0; + + if (ch === 'O') { + // ESC O letter + // ESC O modifier letter + s += (ch = yield); + + if (ch >= '0' && ch <= '9') { + modifier = (ch >> 0) - 1; + s += (ch = yield); + } + + code += ch; + } else if (ch === '[') { + // ESC [ letter + // ESC [ modifier letter + // ESC [ [ modifier letter + // ESC [ [ num char + s += (ch = yield); + + if (ch === '[') { + // \x1b[[A + // ^--- escape codes might have a second bracket + code += ch; + s += (ch = yield); + } + + /* + * Here and later we try to buffer just enough data to get + * a complete ascii sequence. + * + * We have basically two classes of ascii characters to process: + * + * + * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } + * + * This particular example is featuring Ctrl+F12 in xterm. + * + * - `;5` part is optional, e.g. it could be `\x1b[24~` + * - first part can contain one or two digits + * + * So the generic regexp is like /^\d\d?(;\d)?[~^$]$/ + * + * + * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } + * + * This particular example is featuring Ctrl+Home in xterm. + * + * - `1;5` part is optional, e.g. it could be `\x1b[H` + * - `1;` part is optional, e.g. it could be `\x1b[5H` + * + * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ + * + */ + const cmdStart = s.length - 1; + + // Skip one or two leading digits + if (ch >= '0' && ch <= '9') { + s += (ch = yield); + + if (ch >= '0' && ch <= '9') { + s += (ch = yield); + } + } + + // skip modifier + if (ch === ';') { + s += (ch = yield); + + if (ch >= '0' && ch <= '9') { + s += yield; + } + } + + /* + * We buffered enough data, now trying to extract code + * and modifier from it + */ + const cmd = String.prototype.slice.call(s, cmdStart); + let match; + + if ((match = RegExp.prototype.exec.call(/^(\d\d?)(;(\d))?([~^$])$/, cmd))) { + code += match[1] + match[4]; + modifier = (match[3] || 1) - 1; + } else if ( + (match = RegExp.prototype.exec.call(/^((\d;)?(\d))?([A-Za-z])$/, cmd)) + ) { + code += match[4]; + modifier = (match[3] || 1) - 1; + } else { + code += cmd; + } + } + + // Parse the key modifier + key.ctrl = !!(modifier & 4); + key.meta = !!(modifier & 10); + key.shift = !!(modifier & 1); + key.code = code; + + // Parse the key itself + switch (code) { + /* xterm/gnome ESC [ letter (with modifier) */ + case '[P': key.name = 'f1'; break; + case '[Q': key.name = 'f2'; break; + case '[R': key.name = 'f3'; break; + case '[S': key.name = 'f4'; break; + + /* xterm/gnome ESC O letter (without modifier) */ + case 'OP': key.name = 'f1'; break; + case 'OQ': key.name = 'f2'; break; + case 'OR': key.name = 'f3'; break; + case 'OS': key.name = 'f4'; break; + + /* xterm/rxvt ESC [ number ~ */ + case '[11~': key.name = 'f1'; break; + case '[12~': key.name = 'f2'; break; + case '[13~': key.name = 'f3'; break; + case '[14~': key.name = 'f4'; break; + + /* from Cygwin and used in libuv */ + case '[[A': key.name = 'f1'; break; + case '[[B': key.name = 'f2'; break; + case '[[C': key.name = 'f3'; break; + case '[[D': key.name = 'f4'; break; + case '[[E': key.name = 'f5'; break; + + /* common */ + case '[15~': key.name = 'f5'; break; + case '[17~': key.name = 'f6'; break; + case '[18~': key.name = 'f7'; break; + case '[19~': key.name = 'f8'; break; + case '[20~': key.name = 'f9'; break; + case '[21~': key.name = 'f10'; break; + case '[23~': key.name = 'f11'; break; + case '[24~': key.name = 'f12'; break; + + /* xterm ESC [ letter */ + case '[A': key.name = 'up'; break; + case '[B': key.name = 'down'; break; + case '[C': key.name = 'right'; break; + case '[D': key.name = 'left'; break; + case '[E': key.name = 'clear'; break; + case '[F': key.name = 'end'; break; + case '[H': key.name = 'home'; break; + + /* xterm/gnome ESC O letter */ + case 'OA': key.name = 'up'; break; + case 'OB': key.name = 'down'; break; + case 'OC': key.name = 'right'; break; + case 'OD': key.name = 'left'; break; + case 'OE': key.name = 'clear'; break; + case 'OF': key.name = 'end'; break; + case 'OH': key.name = 'home'; break; + + /* xterm/rxvt ESC [ number ~ */ + case '[1~': key.name = 'home'; break; + case '[2~': key.name = 'insert'; break; + case '[3~': key.name = 'delete'; break; + case '[4~': key.name = 'end'; break; + case '[5~': key.name = 'pageup'; break; + case '[6~': key.name = 'pagedown'; break; + + /* putty */ + case '[[5~': key.name = 'pageup'; break; + case '[[6~': key.name = 'pagedown'; break; + + /* rxvt */ + case '[7~': key.name = 'home'; break; + case '[8~': key.name = 'end'; break; + + /* rxvt keys with modifiers */ + case '[a': key.name = 'up'; key.shift = true; break; + case '[b': key.name = 'down'; key.shift = true; break; + case '[c': key.name = 'right'; key.shift = true; break; + case '[d': key.name = 'left'; key.shift = true; break; + case '[e': key.name = 'clear'; key.shift = true; break; + + case '[2$': key.name = 'insert'; key.shift = true; break; + case '[3$': key.name = 'delete'; key.shift = true; break; + case '[5$': key.name = 'pageup'; key.shift = true; break; + case '[6$': key.name = 'pagedown'; key.shift = true; break; + case '[7$': key.name = 'home'; key.shift = true; break; + case '[8$': key.name = 'end'; key.shift = true; break; + + case 'Oa': key.name = 'up'; key.ctrl = true; break; + case 'Ob': key.name = 'down'; key.ctrl = true; break; + case 'Oc': key.name = 'right'; key.ctrl = true; break; + case 'Od': key.name = 'left'; key.ctrl = true; break; + case 'Oe': key.name = 'clear'; key.ctrl = true; break; + + case '[2^': key.name = 'insert'; key.ctrl = true; break; + case '[3^': key.name = 'delete'; key.ctrl = true; break; + case '[5^': key.name = 'pageup'; key.ctrl = true; break; + case '[6^': key.name = 'pagedown'; key.ctrl = true; break; + case '[7^': key.name = 'home'; key.ctrl = true; break; + case '[8^': key.name = 'end'; key.ctrl = true; break; + + /* misc. */ + case '[Z': key.name = 'tab'; key.shift = true; break; + default: key.name = 'undefined'; break; + } + } else if (ch === '\r') { + // carriage return + key.name = 'return'; + key.meta = escaped; + } else if (ch === '\n') { + // Enter, should have been called linefeed + key.name = 'enter'; + key.meta = escaped; + } else if (ch === '\t') { + // tab + key.name = 'tab'; + key.meta = escaped; + } else if (ch === '\b' || ch === '\x7f') { + // backspace or ctrl+h + key.name = 'backspace'; + key.meta = escaped; + } else if (ch === kEscape) { + // escape key + key.name = 'escape'; + key.meta = escaped; + } else if (ch === ' ') { + key.name = 'space'; + key.meta = escaped; + } else if (!escaped && ch <= '\x1a') { + // ctrl+letter + key.name = String.fromCharCode( + String.prototype.charCodeAt.call(ch) + String.prototype.charCodeAt.call('a') - 1 + ); + key.ctrl = true; + } else if (RegExp.prototype.exec.call(/^[0-9A-Za-z]$/, ch) !== null) { + // Letter, number, shift+letter + key.name = String.prototype.toLowerCase.call(ch); + key.shift = RegExp.prototype.exec.call(/^[A-Z]$/, ch) !== null; + key.meta = escaped; + } else if (escaped) { + // Escape sequence timeout + key.name = ch.length ? undefined : 'escape'; + key.meta = true; + } + + key.sequence = s; + + if (s.length !== 0 && (key.name !== undefined || escaped)) { + /* Named character or sequence */ + stream.emit('keypress', escaped ? undefined : s, key); + } else if (charLengthAt(s, 0) === s.length) { + /* Single unnamed character, e.g. "." */ + stream.emit('keypress', s, key); + } + /* Unrecognized or broken escape sequence, don't emit anything */ + } +} + +// This runs in O(n log n). +function commonPrefix(strings) { + if (strings.length === 0) { + return ''; + } + if (strings.length === 1) { + return strings[0]; + } + const sorted = Array.prototype.sort.call(Array.prototype.slice.call(strings)); + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + for (let i = 0; i < min.length; i++) { + if (min[i] !== max[i]) { + return String.prototype.slice.call(min, 0, i); + } + } + return min; +} + +export { + charLengthAt, + charLengthLeft, + commonPrefix, + emitKeys, + kSubstringSearch, + CSI +}; + +export default { + charLengthAt, + charLengthLeft, + commonPrefix, + emitKeys, + kSubstringSearch, + CSI +}; diff --git a/modules/internal/util.js b/modules/internal/util.js index 6b91fe9..75cbf12 100644 --- a/modules/internal/util.js +++ b/modules/internal/util.js @@ -143,6 +143,8 @@ export function promisify( promisify.custom = kCustomPromisifiedSymbol; +export const kEmptyObject = Object.freeze(Object.create(null)); + export default { createDeferredPromise, customInspectSymbol, @@ -151,4 +153,5 @@ export default { once, deprecate, promisify, + kEmptyObject }; \ No newline at end of file diff --git a/modules/readline.js b/modules/readline.js new file mode 100644 index 0000000..17edb7b --- /dev/null +++ b/modules/readline.js @@ -0,0 +1,517 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +import { clearLine, clearScreenDown, cursorTo, moveCursor } from './internal/readline/callbacks'; + +import { emitKeypressEvents } from './internal/readline/emitKeypressEvents'; + +import promises from 'readline/promises'; + +import { AbortError } from './internal/errors'; + +import { inspect } from './internal/util/inspect'; + +import { kEmptyObject, promisify } from './internal/util'; + +import { validateAbortSignal } from './internal/validators'; + +/** + * @typedef {import('./stream.js').Readable} Readable + * @typedef {import('./stream.js').Writable} Writable + */ + +const { + Interface: _Interface, + InterfaceConstructor, + kAddHistory, + kDecoder, + kDeleteLeft, + kDeleteLineLeft, + kDeleteLineRight, + kDeleteRight, + kDeleteWordLeft, + kDeleteWordRight, + kGetDisplayPos, + kHistoryNext, + kHistoryPrev, + kInsertString, + kLine, + kLine_buffer, + kMoveCursor, + kNormalWrite, + kOldPrompt, + kOnLine, + kPreviousKey, + kPrompt, + kQuestionCallback, + kQuestionCancel, + kRefreshLine, + kSawKeyPress, + kSawReturnAt, + kSetRawMode, + kTabComplete, + kTabCompleter, + kTtyWrite, + kWordLeft, + kWordRight, + kWriteToOutput, +} = require('internal/readline/interface'); + +function Interface(input, output, completer, terminal) { + if (!(this instanceof Interface)) { + return new Interface(input, output, completer, terminal); + } + + if (input?.input && + typeof input.completer === 'function' && input.completer.length !== 2) { + const { completer } = input; + input.completer = (v, cb) => cb(null, completer(v)); + } else if (typeof completer === 'function' && completer.length !== 2) { + const realCompleter = completer; + completer = (v, cb) => cb(null, realCompleter(v)); + } + + Function.prototype.call.call(InterfaceConstructor, this, + input, output, completer, terminal); + + if (process.env.TERM === 'dumb') { + this._ttyWrite = Function.prototype.bind.call(_ttyWriteDumb, this); + } +} + +Object.setPrototypeOf(Interface.prototype, _Interface.prototype); +Object.setPrototypeOf(Interface, _Interface); + +const superQuestion = _Interface.prototype.question; + +/** + * Displays `query` by writing it to the `output`. + * @param {string} query + * @param {{ signal?: AbortSignal; }} [options] + * @param {Function} cb + * @returns {void} + */ +Interface.prototype.question = function (query, options, cb) { + cb = typeof options === 'function' ? options : cb; + if (options === null || typeof options !== 'object') { + options = kEmptyObject; + } + + if (options.signal) { + validateAbortSignal(options.signal, 'options.signal'); + if (options.signal.aborted) { + return; + } + + const onAbort = () => { + this[kQuestionCancel](); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => { + options.signal.removeEventListener(onAbort); + }; + const originalCb = cb; + cb = typeof cb === 'function' ? (answer) => { + cleanup(); + return originalCb(answer); + } : cleanup; + } + + if (typeof cb === 'function') { + Function.prototype.call.call(superQuestion, this, query, cb); + } +}; +Interface.prototype.question[promisify.custom] = function question(query, options) { + if (options === null || typeof options !== 'object') { + options = kEmptyObject; + } + + if (options.signal && options.signal.aborted) { + return Promise.reject( + new AbortError(undefined, { cause: options.signal.reason })); + } + + return new Promise((resolve, reject) => { + let cb = resolve; + + if (options.signal) { + const onAbort = () => { + reject(new AbortError(undefined, { cause: options.signal.reason })); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + cb = (answer) => { + options.signal.removeEventListener('abort', onAbort); + resolve(answer); + }; + } + + this.question(query, options, cb); + }); +}; + +/** + * Creates a new `readline.Interface` instance. + * @param {Readable | { + * input: Readable; + * output: Writable; + * completer?: Function; + * terminal?: boolean; + * history?: string[]; + * historySize?: number; + * removeHistoryDuplicates?: boolean; + * prompt?: string; + * crlfDelay?: number; + * escapeCodeTimeout?: number; + * tabSize?: number; + * signal?: AbortSignal; + * }} input + * @param {Writable} [output] + * @param {Function} [completer] + * @param {boolean} [terminal] + * @returns {Interface} + */ +function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + +Object.defineProperties(Interface.prototype, { + // Redirect internal prototype methods to the underscore notation for backward + // compatibility. + [kSetRawMode]: { + __proto__: null, + get() { + return this._setRawMode; + } + }, + [kOnLine]: { + __proto__: null, + get() { + return this._onLine; + } + }, + [kWriteToOutput]: { + __proto__: null, + get() { + return this._writeToOutput; + } + }, + [kAddHistory]: { + __proto__: null, + get() { + return this._addHistory; + } + }, + [kRefreshLine]: { + __proto__: null, + get() { + return this._refreshLine; + } + }, + [kNormalWrite]: { + __proto__: null, + get() { + return this._normalWrite; + } + }, + [kInsertString]: { + __proto__: null, + get() { + return this._insertString; + } + }, + [kTabComplete]: { + __proto__: null, + get() { + return this._tabComplete; + } + }, + [kWordLeft]: { + __proto__: null, + get() { + return this._wordLeft; + } + }, + [kWordRight]: { + __proto__: null, + get() { + return this._wordRight; + } + }, + [kDeleteLeft]: { + __proto__: null, + get() { + return this._deleteLeft; + } + }, + [kDeleteRight]: { + __proto__: null, + get() { + return this._deleteRight; + } + }, + [kDeleteWordLeft]: { + __proto__: null, + get() { + return this._deleteWordLeft; + } + }, + [kDeleteWordRight]: { + __proto__: null, + get() { + return this._deleteWordRight; + } + }, + [kDeleteLineLeft]: { + __proto__: null, + get() { + return this._deleteLineLeft; + } + }, + [kDeleteLineRight]: { + __proto__: null, + get() { + return this._deleteLineRight; + } + }, + [kLine]: { + __proto__: null, + get() { + return this._line; + } + }, + [kHistoryNext]: { + __proto__: null, + get() { + return this._historyNext; + } + }, + [kHistoryPrev]: { + __proto__: null, + get() { + return this._historyPrev; + } + }, + [kGetDisplayPos]: { + __proto__: null, + get() { + return this._getDisplayPos; + } + }, + [kMoveCursor]: { + __proto__: null, + get() { + return this._moveCursor; + } + }, + [kTtyWrite]: { + __proto__: null, + get() { + return this._ttyWrite; + } + }, + + // Defining proxies for the internal instance properties for backward + // compatibility. + _decoder: { + __proto__: null, + get() { + return this[kDecoder]; + }, + set(value) { + this[kDecoder] = value; + }, + }, + _line_buffer: { + __proto__: null, + get() { + return this[kLine_buffer]; + }, + set(value) { + this[kLine_buffer] = value; + }, + }, + _oldPrompt: { + __proto__: null, + get() { + return this[kOldPrompt]; + }, + set(value) { + this[kOldPrompt] = value; + }, + }, + _previousKey: { + __proto__: null, + get() { + return this[kPreviousKey]; + }, + set(value) { + this[kPreviousKey] = value; + }, + }, + _prompt: { + __proto__: null, + get() { + return this[kPrompt]; + }, + set(value) { + this[kPrompt] = value; + }, + }, + _questionCallback: { + __proto__: null, + get() { + return this[kQuestionCallback]; + }, + set(value) { + this[kQuestionCallback] = value; + }, + }, + _sawKeyPress: { + __proto__: null, + get() { + return this[kSawKeyPress]; + }, + set(value) { + this[kSawKeyPress] = value; + }, + }, + _sawReturnAt: { + __proto__: null, + get() { + return this[kSawReturnAt]; + }, + set(value) { + this[kSawReturnAt] = value; + }, + }, +}); + +// Make internal methods public for backward compatibility. +Interface.prototype._setRawMode = _Interface.prototype[kSetRawMode]; +Interface.prototype._onLine = _Interface.prototype[kOnLine]; +Interface.prototype._writeToOutput = _Interface.prototype[kWriteToOutput]; +Interface.prototype._addHistory = _Interface.prototype[kAddHistory]; +Interface.prototype._refreshLine = _Interface.prototype[kRefreshLine]; +Interface.prototype._normalWrite = _Interface.prototype[kNormalWrite]; +Interface.prototype._insertString = _Interface.prototype[kInsertString]; +Interface.prototype._tabComplete = function (lastKeypressWasTab) { + // Overriding parent method because `this.completer` in the legacy + // implementation takes a callback instead of being an async function. + this.pause(); + const string = String.prototype.slice.call(this.line, 0, this.cursor); + this.completer(string, (err, value) => { + this.resume(); + + if (err) { + this._writeToOutput(`Tab completion error: ${inspect(err)}`); + return; + } + + this[kTabCompleter](lastKeypressWasTab, value); + }); +}; +Interface.prototype._wordLeft = _Interface.prototype[kWordLeft]; +Interface.prototype._wordRight = _Interface.prototype[kWordRight]; +Interface.prototype._deleteLeft = _Interface.prototype[kDeleteLeft]; +Interface.prototype._deleteRight = _Interface.prototype[kDeleteRight]; +Interface.prototype._deleteWordLeft = _Interface.prototype[kDeleteWordLeft]; +Interface.prototype._deleteWordRight = _Interface.prototype[kDeleteWordRight]; +Interface.prototype._deleteLineLeft = _Interface.prototype[kDeleteLineLeft]; +Interface.prototype._deleteLineRight = _Interface.prototype[kDeleteLineRight]; +Interface.prototype._line = _Interface.prototype[kLine]; +Interface.prototype._historyNext = _Interface.prototype[kHistoryNext]; +Interface.prototype._historyPrev = _Interface.prototype[kHistoryPrev]; +Interface.prototype._getDisplayPos = _Interface.prototype[kGetDisplayPos]; +Interface.prototype._getCursorPos = _Interface.prototype.getCursorPos; +Interface.prototype._moveCursor = _Interface.prototype[kMoveCursor]; +Interface.prototype._ttyWrite = _Interface.prototype[kTtyWrite]; + +function _ttyWriteDumb(s, key) { + key = key || kEmptyObject; + + if (key.name === 'escape') return; + + if (this[kSawReturnAt] && key.name !== 'enter') + this[kSawReturnAt] = 0; + + if (key.ctrl) { + if (key.name === 'c') { + if (this.listenerCount('SIGINT') > 0) { + this.emit('SIGINT'); + } else { + // This readline instance is finished + this.close(); + } + + return; + } else if (key.name === 'd') { + this.close(); + return; + } + } + + switch (key.name) { + case 'return': // Carriage return, i.e. \r + this[kSawReturnAt] = DateNow(); + this._line(); + break; + + case 'enter': + // When key interval > crlfDelay + if (this[kSawReturnAt] === 0 || + DateNow() - this[kSawReturnAt] > this.crlfDelay) { + this._line(); + } + this[kSawReturnAt] = 0; + break; + + default: + if (typeof s === 'string' && s) { + this.line += s; + this.cursor += s.length; + this._writeToOutput(s); + } + } +} + +export { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + emitKeypressEvents, + moveCursor, + promises, +}; + +export default { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + emitKeypressEvents, + moveCursor, + promises, +}; diff --git a/modules/tty.js b/modules/tty.js index f6b72b2..d9634d2 100644 --- a/modules/tty.js +++ b/modules/tty.js @@ -1,95 +1,154 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +import net from 'net'; import internal from "_node:tty" -import fs from "fs" -import { hasColors, getColorDepth } from "./internal/tty"; +import { ERR_INVALID_FD, ERR_TTY_INIT_FAILED } from './internal/errors'; +import { + getColorDepth, + hasColors +} from './internal/tty'; -export function isatty(fd) { - return Number.isInteger(fd) && fd >= 0 && fd <= 2147483647 && (internal.isatty(fd) ?? false); + +import readline from './readline'; + +function isatty(fd) { + return Number.isInteger(fd) && fd >= 0 && fd <= 2147483647 && + (internal.isatty(fd) ?? false); } -// net.Socket is unimplemented now, use fs.ReadStream temporarily -export class ReadStream extends fs.ReadStream { - constructor(fd) { - super("", { fd }); - } +function ReadStream(fd, options) { + if (!(this instanceof ReadStream)) + return new ReadStream(fd, options); + if (fd >> 0 !== fd || fd < 0) + throw new ERR_INVALID_FD(fd); - get isTTY() { - return true; + const ctx = {}; + const tty = new TTY(fd, ctx); + if (ctx.code !== undefined) { + throw new ERR_TTY_INIT_FAILED(ctx); } - get isRaw() { - return false; - } + net.Socket.call(this, { + readableHighWaterMark: 0, + handle: tty, + manualStart: true, + ...options + }); - setRawMode(mode) { - // require tcgetattr and tcsetattr or ioctl, unsupported - return this; - } + this.isRaw = false; + this.isTTY = true; } -export class WriteStream extends fs.WriteStream { - constructor(fd) { - super("", { fd }); - } - - clearLine(dir, callback) { - if (dir === -1) { - this.write("\x1b[K"); // clear left - } else if (dir === 1) { - this.write("\x1b[1K"); // clear right - } else if (dir === 2) { - this.write("\x1b[2K"); // clear all - } - if (typeof (callback) === "function") { - callback(); - } - return true; - } +Object.setPrototypeOf(ReadStream.prototype, net.Socket.prototype); +Object.setPrototypeOf(ReadStream, net.Socket); - clearScreenDown(callback) { - this.write("\x1b[J"); - if (typeof (callback) === "function") { - callback(); - } +ReadStream.prototype.setRawMode = function (flag) { + flag = !!flag; + const err = this._handle?.setRawMode(flag); + if (err) { + this.emit('error', errors.errnoException(err, 'setRawMode')); + return this; } - - getWindowSize() { - // require tcgetwinsize, unsupported - return [undefined, undefined]; + this.isRaw = flag; + return this; +}; + +function WriteStream(fd) { + if (!(this instanceof WriteStream)) + return new WriteStream(fd); + if (fd >> 0 !== fd || fd < 0) + throw new ERR_INVALID_FD(fd); + + const ctx = {}; + const tty = new TTY(fd, ctx); + if (ctx.code !== undefined) { + throw new ERR_TTY_INIT_FAILED(ctx); } - get columns() { - return this.getWindowSize[0]; + net.Socket.call(this, { + readableHighWaterMark: 0, + handle: tty, + manualStart: true + }); + + // Prevents interleaved or dropped stdout/stderr output for terminals. + // As noted in the following reference, local TTYs tend to be quite fast and + // this behavior has become expected due historical functionality on OS X, + // even though it was originally intended to change in v1.0.2 (Libuv 1.2.1). + // Ref: https://github.com/nodejs/node/pull/1771#issuecomment-119351671 + this._handle.setBlocking(true); + + const winSize = new Array(2); + const err = this._handle.getWindowSize(winSize); + if (!err) { + this.columns = winSize[0]; + this.rows = winSize[1]; } +} - get rows() { - return this.getWindowSize[1]; - } +Object.setPrototypeOf(WriteStream.prototype, net.Socket.prototype); +Object.setPrototypeOf(WriteStream, net.Socket); - getColorDepth = getColorDepth; +WriteStream.prototype.isTTY = true; - hasColors = hasColors; +WriteStream.prototype.getColorDepth = getColorDepth; - get isTTY() { - return true; - } +WriteStream.prototype.hasColors = hasColors; - cursorTo(x, y, callback) { - this.write(`\x1b[${x};${y}`); - if (typeof (callback) === "function") { - callback(); - } - return true; +WriteStream.prototype._refreshSize = function () { + const oldCols = this.columns; + const oldRows = this.rows; + const winSize = new Array(2); + const err = this._handle.getWindowSize(winSize); + if (err) { + this.emit('error', errors.errnoException(err, 'getWindowSize')); + return; } - - moveCursor(dx, dy, callback) { - this.write((dx > 0 ? `\x1b[${dx}C` : `\x1b[${-dx}D`) + (dy > 0 ? `\x1b[${dy}B` : `\x1b[${-dy}A`)) - if (typeof (callback) === "function") { - callback(); - } - return true; + const { 0: newCols, 1: newRows } = winSize; + if (oldCols !== newCols || oldRows !== newRows) { + this.columns = newCols; + this.rows = newRows; + this.emit('resize'); } -} - -export default { - isatty -} \ No newline at end of file +}; + +// Backwards-compat +WriteStream.prototype.cursorTo = function (x, y, callback) { + return readline.cursorTo(this, x, y, callback); +}; +WriteStream.prototype.moveCursor = function (dx, dy, callback) { + return readline.moveCursor(this, dx, dy, callback); +}; +WriteStream.prototype.clearLine = function (dir, callback) { + return readline.clearLine(this, dir, callback); +}; +WriteStream.prototype.clearScreenDown = function (callback) { + return readline.clearScreenDown(this, callback); +}; +WriteStream.prototype.getWindowSize = function () { + return [this.columns, this.rows]; +}; + +export { isatty, ReadStream, WriteStream }; +export default { isatty, ReadStream, WriteStream };