diff options
author | 2023-01-08 03:49:49 -0600 | |
---|---|---|
committer | 2023-01-08 01:49:49 -0800 | |
commit | 94409770dece8bb9dfc23f4bdc2f240836035d87 (patch) | |
tree | 4cc627eb67c476871e84141a6c7a583e29e98309 | |
parent | c505f172b84f5359aa186513f3ef7d6394bfc7b2 (diff) | |
download | bun-94409770dece8bb9dfc23f4bdc2f240836035d87.tar.gz bun-94409770dece8bb9dfc23f4bdc2f240836035d87.tar.zst bun-94409770dece8bb9dfc23f4bdc2f240836035d87.zip |
feat(node:readline): add node:readline and node:readline/promises (#1738)
* feat(readline): WIP: add readline
* test(helpers): add deepStrictEqual helper
* feat(readline): add readline & readline/promises to loader
* fix(node:events): emit newListener on new listener added
* feat(readline): finish readline cb interface, add tests
* fix(stream): fix Transform.end()
* fix(node-test-helpers): correct throws behavior, improve how all asserts work
* feat(readline/promises): add readline/promises
* feat(assert): add assert.match
* test(readline): uncomment more tests
* fix(readline): MaxCeil -> MathCeil đ¤Ś
* fix(readline): export promises from node:readline
* fix(readline): temp fix for circular dependency
* cleanup(readline): remove console.log
* fix(readline): change true -> 0 for CommonJS export
* perf(readline): micro-optimizations with some getters
* perf(readline): lazy load isWritable
* cleanup(readline): rename debug flag env var to BUN_JS_DEBUG
-rw-r--r-- | src/bun.js/assert.exports.js | 24 | ||||
-rw-r--r-- | src/bun.js/bindings/webcore/JSEventEmitter.cpp | 9 | ||||
-rw-r--r-- | src/bun.js/module_loader.zig | 26 | ||||
-rw-r--r-- | src/bun.js/readline.exports.js | 3128 | ||||
-rw-r--r-- | src/bun.js/readline_promises.exports.js | 218 | ||||
-rw-r--r-- | src/bun.js/streams.exports.js | 2 | ||||
-rw-r--r-- | test/bun.js/node-test-helpers.js | 156 | ||||
-rw-r--r-- | test/bun.js/node-test-helpers.ts | 198 | ||||
-rw-r--r-- | test/bun.js/readline.node.test.ts | 2018 | ||||
-rw-r--r-- | test/bun.js/readline_promises.node.test.ts | 55 |
10 files changed, 5673 insertions, 161 deletions
diff --git a/src/bun.js/assert.exports.js b/src/bun.js/assert.exports.js index a66c0c241..179d8494d 100644 --- a/src/bun.js/assert.exports.js +++ b/src/bun.js/assert.exports.js @@ -1195,6 +1195,20 @@ var require_assert = __commonJS({ stackStartFn: notStrictEqual, }); }; + assert.match = function match(actual, expected, message) { + if (arguments.length < 2) + throw new ERR_MISSING_ARGS("actual", "expected"); + if (!isRegExp(expected)) + throw new ERR_INVALID_ARG_TYPE("expected", "RegExp", expected); + expected.test(actual) || + innerFail({ + actual, + expected, + message, + operator: "match", + stackStartFn: match, + }); + }; var Comparison = function Comparison2(obj, keys, actual) { var _this = this; _classCallCheck(this, Comparison2), @@ -1264,10 +1278,12 @@ var require_assert = __commonJS({ ); return ( keys.forEach(function (key) { - (typeof actual[key] == "string" && - isRegExp(expected[key]) && - expected[key].test(actual[key])) || - compareExceptionKey(actual, expected, key, msg, keys, fn); + return ( + (typeof actual[key] == "string" && + isRegExp(expected[key]) && + expected[key].test(actual[key])) || + compareExceptionKey(actual, expected, key, msg, keys, fn) + ); }), !0 ); diff --git a/src/bun.js/bindings/webcore/JSEventEmitter.cpp b/src/bun.js/bindings/webcore/JSEventEmitter.cpp index b50f77cae..8d7d1ca25 100644 --- a/src/bun.js/bindings/webcore/JSEventEmitter.cpp +++ b/src/bun.js/bindings/webcore/JSEventEmitter.cpp @@ -237,6 +237,15 @@ static inline JSC::EncodedJSValue addListener(JSC::JSGlobalObject* lexicalGlobal RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); auto result = JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.addListenerForBindings(WTFMove(eventType), WTFMove(listener), once, prepend); })); RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + + JSC::Identifier newListenerEventType = JSC::Identifier::fromString(vm, "newListener"_s); + JSC::MarkedArgumentBuffer args; + args.append(argument0.value()); + args.append(argument1.value()); + + auto result2 = JSValue::encode(toJS<IDLBoolean>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.emitForBindings(WTFMove(newListenerEventType), WTFMove(args)); })); + RETURN_IF_EXCEPTION(throwScope, encodedJSValue()); + vm.writeBarrier(&static_cast<JSObject&>(*castedThis), argument1.value()); RELEASE_AND_RETURN(throwScope, JSValue::encode(actualThis)); } diff --git a/src/bun.js/module_loader.zig b/src/bun.js/module_loader.zig index 6432fb7ca..46b7d0c09 100644 --- a/src/bun.js/module_loader.zig +++ b/src/bun.js/module_loader.zig @@ -1710,6 +1710,24 @@ pub const ModuleLoader = struct { .hash = 0, }; }, + .@"node:readline" => { + return ResolvedSource{ + .allocator = null, + .source_code = ZigString.init(jsModuleFromFile(jsc_vm.load_builtins_from_path, "readline.exports.js")), + .specifier = ZigString.init("node:readline"), + .source_url = ZigString.init("node:readline"), + .hash = 0, + }; + }, + .@"node:readline/promises" => { + return ResolvedSource{ + .allocator = null, + .source_code = ZigString.init(jsModuleFromFile(jsc_vm.load_builtins_from_path, "readline_promises.exports.js")), + .specifier = ZigString.init("node:readline/promises"), + .source_url = ZigString.init("node:readline/promises"), + .hash = 0, + }; + }, .@"bun:ffi" => { return ResolvedSource{ .allocator = null, @@ -2016,6 +2034,8 @@ pub const HardcodedModule = enum { @"node:path/win32", @"node:perf_hooks", @"node:process", + @"node:readline", + @"node:readline/promises", @"node:stream", @"node:stream/consumers", @"node:stream/web", @@ -2061,6 +2081,8 @@ pub const HardcodedModule = enum { .{ "node:path/win32", HardcodedModule.@"node:path/win32" }, .{ "node:perf_hooks", HardcodedModule.@"node:perf_hooks" }, .{ "node:process", HardcodedModule.@"node:process" }, + .{ "node:readline", HardcodedModule.@"node:readline" }, + .{ "node:readline/promises", HardcodedModule.@"node:readline/promises" }, .{ "node:stream", HardcodedModule.@"node:stream" }, .{ "node:stream/consumers", HardcodedModule.@"node:stream/consumers" }, .{ "node:stream/web", HardcodedModule.@"node:stream/web" }, @@ -2119,6 +2141,8 @@ pub const HardcodedModule = enum { .{ "node:path/win32", "node:path/win32" }, .{ "node:perf_hooks", "node:perf_hooks" }, .{ "node:process", "node:process" }, + .{ "node:readline", "node:readline" }, + .{ "node:readline/promises", "node:readline/promises" }, .{ "node:stream", "node:stream" }, .{ "node:stream/consumers", "node:stream/consumers" }, .{ "node:stream/web", "node:stream/web" }, @@ -2138,6 +2162,8 @@ pub const HardcodedModule = enum { .{ "readable-stream", "node:stream" }, .{ "readable-stream/consumer", "node:stream/consumers" }, .{ "readable-stream/web", "node:stream/web" }, + .{ "readline", "node:readline" }, + .{ "readline/promises", "node:readline/promises" }, .{ "stream", "node:stream" }, .{ "stream/consumers", "node:stream/consumers" }, .{ "stream/web", "node:stream/web" }, diff --git a/src/bun.js/readline.exports.js b/src/bun.js/readline.exports.js new file mode 100644 index 000000000..73b8d24c5 --- /dev/null +++ b/src/bun.js/readline.exports.js @@ -0,0 +1,3128 @@ +// Attribution: Some parts of of this module are derived from code originating from the Node.js +// readline module which is licensed under an MIT license: +// +// Copyright Node.js contributors. All rights reserved. +// +// 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. + +// ---------------------------------------------------------------------------- +// Section: Imports +// ---------------------------------------------------------------------------- +var { Array, RegExp, String, Bun } = import.meta.primordials; +var EventEmitter = import.meta.require("node:events"); +var { clearTimeout, setTimeout } = import.meta.require("timers"); +var { StringDecoder } = import.meta.require("string_decoder"); + +var { inspect } = Bun; +var debug = process.env.BUN_JS_DEBUG ? console.log : () => {}; + +// ---------------------------------------------------------------------------- +// Section: Preamble +// ---------------------------------------------------------------------------- + +var SymbolAsyncIterator = Symbol.asyncIterator; +var SymbolIterator = Symbol.iterator; +var SymbolFor = Symbol.for; +var SymbolReplace = Symbol.replace; +var ArrayFrom = Array.from; +var ArrayIsArray = Array.isArray; +var ArrayPrototypeFilter = Array.prototype.filter; +var ArrayPrototypeSort = Array.prototype.sort; +var ArrayPrototypeIndexOf = Array.prototype.indexOf; +var ArrayPrototypeJoin = Array.prototype.join; +var ArrayPrototypeMap = Array.prototype.map; +var ArrayPrototypePop = Array.prototype.pop; +var ArrayPrototypePush = Array.prototype.push; +var ArrayPrototypeSlice = Array.prototype.slice; +var ArrayPrototypeSplice = Array.prototype.splice; +var ArrayPrototypeReverse = Array.prototype.reverse; +var ArrayPrototypeShift = Array.prototype.shift; +var ArrayPrototypeUnshift = Array.prototype.unshift; +var RegExpPrototypeExec = RegExp.prototype.exec; +var RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace]; +var StringFromCharCode = String.fromCharCode; +var StringPrototypeCharCodeAt = String.prototype.charCodeAt; +var StringPrototypeCodePointAt = String.prototype.codePointAt; +var StringPrototypeSlice = String.prototype.slice; +var StringPrototypeToLowerCase = String.prototype.toLowerCase; +var StringPrototypeEndsWith = String.prototype.endsWith; +var StringPrototypeRepeat = String.prototype.repeat; +var StringPrototypeStartsWith = String.prototype.startsWith; +var StringPrototypeTrim = String.prototype.trim; +var StringPrototypeNormalize = String.prototype.normalize; +var NumberIsNaN = Number.isNaN; +var NumberIsFinite = Number.isFinite; +var NumberIsInteger = Number.isInteger; +var NumberMAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +var NumberMIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER; +var MathCeil = Math.ceil; +var MathFloor = Math.floor; +var MathMax = Math.max; +var MathMaxApply = Math.max.apply; +var DateNow = Date.now; +var FunctionPrototype = Function.prototype; +var StringPrototype = String.prototype; +var StringPrototypeSymbolIterator = StringPrototype[SymbolIterator]; +var StringIteratorPrototypeNext = StringPrototypeSymbolIterator.call("").next; +var ObjectSetPrototypeOf = Object.setPrototypeOf; +var ObjectDefineProperty = Object.defineProperty; +var ObjectDefineProperties = Object.defineProperties; +var ObjectFreeze = Object.freeze; +var ObjectAssign = Object.assign; +var ObjectCreate = Object.create; +var ObjectKeys = Object.keys; +var ObjectSeal = Object.seal; + +var createSafeIterator = (factory, next) => { + class SafeIterator { + #iterator; + constructor(iterable) { + this.#iterator = factory.call(iterable); + } + next() { + return next.call(this.#iterator); + } + [SymbolIterator]() { + return this; + } + } + ObjectSetPrototypeOf(SafeIterator.prototype, null); + ObjectFreeze(SafeIterator.prototype); + ObjectFreeze(SafeIterator); + return SafeIterator; +}; + +var SafeStringIterator = createSafeIterator( + StringPrototypeSymbolIterator, + StringIteratorPrototypeNext, +); + +// ---------------------------------------------------------------------------- +// Section: "Internal" modules +// ---------------------------------------------------------------------------- + +/** + * Returns true if the character represented by a given + * Unicode code point is full-width. Otherwise returns false. + */ +var isFullWidthCodePoint = (code) => { + // Code points are partially derived from: + // https://www.unicode.org/Public/UNIDATA/EastAsianWidth.txt + return ( + code >= 0x1100 && + (code <= 0x115f || // Hangul Jamo + code === 0x2329 || // LEFT-POINTING ANGLE BRACKET + code === 0x232a || // RIGHT-POINTING ANGLE BRACKET + // CJK Radicals Supplement .. Enclosed CJK Letters and Months + (code >= 0x2e80 && code <= 0x3247 && code !== 0x303f) || + // Enclosed CJK Letters and Months .. CJK Unified Ideographs Extension A + (code >= 0x3250 && code <= 0x4dbf) || + // CJK Unified Ideographs .. Yi Radicals + (code >= 0x4e00 && code <= 0xa4c6) || + // Hangul Jamo Extended-A + (code >= 0xa960 && code <= 0xa97c) || + // Hangul Syllables + (code >= 0xac00 && code <= 0xd7a3) || + // CJK Compatibility Ideographs + (code >= 0xf900 && code <= 0xfaff) || + // Vertical Forms + (code >= 0xfe10 && code <= 0xfe19) || + // CJK Compatibility Forms .. Small Form Variants + (code >= 0xfe30 && code <= 0xfe6b) || + // Halfwidth and Fullwidth Forms + (code >= 0xff01 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + // Kana Supplement + (code >= 0x1b000 && code <= 0x1b001) || + // Enclosed Ideographic Supplement + (code >= 0x1f200 && code <= 0x1f251) || + // Miscellaneous Symbols and Pictographs 0x1f300 - 0x1f5ff + // Emoticons 0x1f600 - 0x1f64f + (code >= 0x1f300 && code <= 0x1f64f) || + // CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane + (code >= 0x20000 && code <= 0x3fffd)) + ); +}; + +var isZeroWidthCodePoint = (code) => { + return ( + code <= 0x1f || // C0 control codes + (code >= 0x7f && code <= 0x9f) || // C1 control codes + (code >= 0x300 && code <= 0x36f) || // Combining Diacritical Marks + (code >= 0x200b && code <= 0x200f) || // Modifying Invisible Characters + // Combining Diacritical Marks for Symbols + (code >= 0x20d0 && code <= 0x20ff) || + (code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors + (code >= 0xfe20 && code <= 0xfe2f) || // Combining Half Marks + (code >= 0xe0100 && code <= 0xe01ef) + ); // Variation Selectors +}; + +/** + * Returns the number of columns required to display the given string. + */ +var getStringWidth = function getStringWidth(str, removeControlChars = true) { + var width = 0; + + if (removeControlChars) str = stripVTControlCharacters(str); + str = StringPrototypeNormalize.call(str, "NFC"); + for (var char of new SafeStringIterator(str)) { + var code = StringPrototypeCodePointAt.call(char, 0); + if (isFullWidthCodePoint(code)) { + width += 2; + } else if (!isZeroWidthCodePoint(code)) { + width++; + } + } + + return width; +}; + +// Regex used for ansi escape code splitting +// Adopted from https://github.com/chalk/ansi-regex/blob/HEAD/index.js +// License: MIT, authors: @sindresorhus, Qix-, arjunmehta and LitoMore +// Matches all ansi escape code sequences in a string +var ansiPattern = + "[\\u001B\\u009B][[\\]()#;?]*" + + "(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*" + + "|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)" + + "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"; +var ansi = new RegExp(ansiPattern, "g"); + +/** + * Remove all VT control characters. Use to estimate displayed string width. + */ +function stripVTControlCharacters(str) { + validateString(str, "str"); + return RegExpPrototypeSymbolReplace.call(ansi, str, ""); +} + +// Promisify + +var kCustomPromisifiedSymbol = SymbolFor("nodejs.util.promisify.custom"); +var kCustomPromisifyArgsSymbol = Symbol("customPromisifyArgs"); + +function promisify(original) { + validateFunction(original, "original"); + + if (original[kCustomPromisifiedSymbol]) { + var fn = original[kCustomPromisifiedSymbol]; + + validateFunction(fn, "util.promisify.custom"); + + return ObjectDefineProperty(fn, kCustomPromisifiedSymbol, { + __proto__: null, + value: fn, + enumerable: false, + writable: false, + configurable: true, + }); + } + + // Names to create an object from in case the callback receives multiple + // arguments, e.g. ['bytesRead', 'buffer'] for fs.read. + var argumentNames = original[kCustomPromisifyArgsSymbol]; + + function fn(...args) { + return new Promise((resolve, reject) => { + ArrayPrototypePush.call(args, (err, ...values) => { + if (err) { + return reject(err); + } + if (argumentNames !== undefined && values.length > 1) { + var obj = {}; + for (var i = 0; i < argumentNames.length; i++) + obj[argumentNames[i]] = values[i]; + resolve(obj); + } else { + resolve(values[0]); + } + }); + ReflectApply(original, this, args); + }); + } + + ObjectSetPrototypeOf(fn, ObjectGetPrototypeOf(original)); + + ObjectDefineProperty(fn, kCustomPromisifiedSymbol, { + __proto__: null, + value: fn, + enumerable: false, + writable: false, + configurable: true, + }); + + var descriptors = ObjectGetOwnPropertyDescriptors(original); + var propertiesValues = ObjectValues(descriptors); + for (var i = 0; i < propertiesValues.length; i++) { + // We want to use null-prototype objects to not rely on globally mutable + // %Object.prototype%. + ObjectSetPrototypeOf(propertiesValues[i], null); + } + return ObjectDefineProperties(fn, descriptors); +} + +promisify.custom = kCustomPromisifiedSymbol; + +// Constants + +var kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 +var kEscape = "\x1b"; +var kSubstringSearch = Symbol("kSubstringSearch"); + +var kIsNodeError = Symbol("kIsNodeError"); + +// Errors +var errorBases = {}; +var VALID_NODE_ERROR_BASES = { + TypeError, + RangeError, + Error, +}; + +function getNodeErrorByName(typeName) { + var base = errorBases[typeName]; + if (base) { + return base; + } + if (!ObjectKeys(VALID_NODE_ERROR_BASES).includes(typeName)) { + throw new Error("Invalid NodeError type"); + } + + var Base = VALID_NODE_ERROR_BASES[typeName]; + + class NodeError extends Base { + [kIsNodeError] = true; + code; + constructor(msg, opts) { + super(msg, opts); + this.code = opts?.code || "ERR_GENERIC"; + } + + toString() { + return `${this.name} [${this.code}]: ${this.message}`; + } + } + errorBases[typeName] = NodeError; + return NodeError; +} + +var NodeError = getNodeErrorByName("Error"); +var NodeTypeError = getNodeErrorByName("TypeError"); +var NodeRangeError = getNodeErrorByName("RangeError"); + +class ERR_INVALID_ARG_TYPE extends NodeTypeError { + constructor(name, type, value) { + super( + `The "${name}" argument must be of type ${type}. Received type ${typeof value}`, + { code: "ERR_INVALID_ARG_TYPE" }, + ); + } +} + +class ERR_INVALID_ARG_VALUE extends NodeTypeError { + constructor(name, value, reason = "not specified") { + super( + `The value "${String( + value, + )}" is invalid for argument '${name}'. Reason: ${reason}`, + { code: "ERR_INVALID_ARG_VALUE" }, + ); + } +} + +class ERR_INVALID_CURSOR_POS extends NodeTypeError { + constructor() { + super("Cannot set cursor row without setting its column", { + code: "ERR_INVALID_CURSOR_POS", + }); + } +} + +class ERR_OUT_OF_RANGE extends NodeRangeError { + constructor(name, range, received) { + super( + `The value of "${name}" is out of range. It must be ${range}. Received ${received}`, + { code: "ERR_OUT_OF_RANGE" }, + ); + } +} + +class ERR_USE_AFTER_CLOSE extends NodeError { + constructor() { + super("This socket has been ended by the other party", { + code: "ERR_USE_AFTER_CLOSE", + }); + } +} + +// Validators + +/** + * @callback validateFunction + * @param {*} value + * @param {string} name + * @returns {asserts value is Function} + */ +function validateFunction(value, name) { + if (typeof value !== "function") + throw new ERR_INVALID_ARG_TYPE(name, "Function", value); +} + +/** + * @callback validateAbortSignal + * @param {*} signal + * @param {string} name + */ +function validateAbortSignal(signal, name) { + if ( + signal !== undefined && + (signal === null || typeof signal !== "object" || !("aborted" in signal)) + ) { + throw new ERR_INVALID_ARG_TYPE(name, "AbortSignal", signal); + } +} + +/** + * @callback validateArray + * @param {*} value + * @param {string} name + * @param {number} [minLength] + * @returns {asserts value is any[]} + */ +function validateArray(value, name, minLength = 0) { + // var validateArray = hideStackFrames((value, name, minLength = 0) => { + if (!ArrayIsArray(value)) { + throw new ERR_INVALID_ARG_TYPE(name, "Array", value); + } + if (value.length < minLength) { + var reason = `must be longer than ${minLength}`; + throw new ERR_INVALID_ARG_VALUE(name, value, reason); + } +} + +/** + * @callback validateString + * @param {*} value + * @param {string} name + * @returns {asserts value is string} + */ +function validateString(value, name) { + if (typeof value !== "string") + throw new ERR_INVALID_ARG_TYPE(name, "string", value); +} + +/** + * @callback validateBoolean + * @param {*} value + * @param {string} name + * @returns {asserts value is boolean} + */ +function validateBoolean(value, name) { + if (typeof value !== "boolean") + throw new ERR_INVALID_ARG_TYPE(name, "boolean", value); +} + +/** + * @callback validateObject + * @param {*} value + * @param {string} name + * @param {{ + * allowArray?: boolean, + * allowFunction?: boolean, + * nullable?: boolean + * }} [options] + */ +function validateObject(value, name, options = null) { + // var validateObject = hideStackFrames((value, name, options = null) => { + var allowArray = options?.allowArray ?? false; + var allowFunction = options?.allowFunction ?? false; + var nullable = options?.nullable ?? false; + if ( + (!nullable && value === null) || + (!allowArray && ArrayIsArray.call(value)) || + (typeof value !== "object" && + (!allowFunction || typeof value !== "function")) + ) { + throw new ERR_INVALID_ARG_TYPE(name, "object", value); + } +} + +/** + * @callback validateInteger + * @param {*} value + * @param {string} name + * @param {number} [min] + * @param {number} [max] + * @returns {asserts value is number} + */ +function validateInteger( + value, + name, + min = NumberMIN_SAFE_INTEGER, + max = NumberMAX_SAFE_INTEGER, +) { + if (typeof value !== "number") + throw new ERR_INVALID_ARG_TYPE(name, "number", value); + if (!NumberIsInteger(value)) + throw new ERR_OUT_OF_RANGE(name, "an integer", value); + if (value < min || value > max) + throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value); +} + +/** + * @callback validateUint32 + * @param {*} value + * @param {string} name + * @param {number|boolean} [positive=false] + * @returns {asserts value is number} + */ +function validateUint32(value, name, positive = false) { + if (typeof value !== "number") { + throw new ERR_INVALID_ARG_TYPE(name, "number", value); + } + + if (!NumberIsInteger(value)) { + throw new ERR_OUT_OF_RANGE(name, "an integer", value); + } + + var min = positive ? 1 : 0; // 2 ** 32 === 4294967296 + var max = 4_294_967_295; + + if (value < min || value > max) { + throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value); + } +} + +// ---------------------------------------------------------------------------- +// Section: Utils +// ---------------------------------------------------------------------------- + +function CSI(strings, ...args) { + var ret = `${kEscape}[`; + for (var n = 0; n < strings.length; n++) { + ret += strings[n]; + if (n < args.length) ret += args[n]; + } + return ret; +} + +var kClearLine, kClearScreenDown, kClearToLineBeginning, kClearToLineEnd; + +CSI.kEscape = kEscape; +CSI.kClearLine = kClearLine = CSI`2K`; +CSI.kClearScreenDown = kClearScreenDown = CSI`0J`; +CSI.kClearToLineBeginning = kClearToLineBeginning = CSI`1K`; +CSI.kClearToLineEnd = kClearToLineEnd = CSI`0K`; + +function charLengthLeft(str, i) { + if (i <= 0) return 0; + if ( + (i > 1 && + StringPrototypeCodePointAt.call(str, i - 2) >= + kUTF16SurrogateThreshold) || + StringPrototypeCodePointAt.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 StringPrototypeCodePointAt.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) { + var ch = yield; + var s = ch; + var escaped = false; + + var keySeq = null; + var keyName; + var keyCtrl = false; + var keyMeta = false; + var keyShift = false; + + // var 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 + var code = ch; + var 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]$/ + * + */ + var 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 + */ + var cmd = StringPrototypeSlice.call(s, cmdStart); + var match; + + if ( + (match = RegExpPrototypeExec.call(/^(\d\d?)(;(\d))?([~^$])$/, cmd)) + ) { + code += match[1] + match[4]; + modifier = (match[3] || 1) - 1; + } else if ( + (match = RegExpPrototypeExec.call(/^((\d;)?(\d))?([A-Za-z])$/, cmd)) + ) { + code += match[4]; + modifier = (match[3] || 1) - 1; + } else { + code += cmd; + } + } + + // Parse the key modifier + keyCtrl = !!(modifier & 4); + keyMeta = !!(modifier & 10); + keyShift = !!(modifier & 1); + keyCode = code; + + // Parse the key itself + switch (code) { + /* xterm/gnome ESC [ letter (with modifier) */ + case "[P": + keyName = "f1"; + break; + case "[Q": + keyName = "f2"; + break; + case "[R": + keyName = "f3"; + break; + case "[S": + keyName = "f4"; + break; + + /* xterm/gnome ESC O letter (without modifier) */ + case "OP": + keyName = "f1"; + break; + case "OQ": + keyName = "f2"; + break; + case "OR": + keyName = "f3"; + break; + case "OS": + keyName = "f4"; + break; + + /* xterm/rxvt ESC [ number ~ */ + case "[11~": + keyName = "f1"; + break; + case "[12~": + keyName = "f2"; + break; + case "[13~": + keyName = "f3"; + break; + case "[14~": + keyName = "f4"; + break; + + /* from Cygwin and used in libuv */ + case "[[A": + keyName = "f1"; + break; + case "[[B": + keyName = "f2"; + break; + case "[[C": + keyName = "f3"; + break; + case "[[D": + keyName = "f4"; + break; + case "[[E": + keyName = "f5"; + break; + + /* common */ + case "[15~": + keyName = "f5"; + break; + case "[17~": + keyName = "f6"; + break; + case "[18~": + keyName = "f7"; + break; + case "[19~": + keyName = "f8"; + break; + case "[20~": + keyName = "f9"; + break; + case "[21~": + keyName = "f10"; + break; + case "[23~": + keyName = "f11"; + break; + case "[24~": + keyName = "f12"; + break; + + /* xterm ESC [ letter */ + case "[A": + keyName = "up"; + break; + case "[B": + keyName = "down"; + break; + case "[C": + keyName = "right"; + break; + case "[D": + keyName = "left"; + break; + case "[E": + keyName = "clear"; + break; + case "[F": + keyName = "end"; + break; + case "[H": + keyName = "home"; + break; + + /* xterm/gnome ESC O letter */ + case "OA": + keyName = "up"; + break; + case "OB": + keyName = "down"; + break; + case "OC": + keyName = "right"; + break; + case "OD": + keyName = "left"; + break; + case "OE": + keyName = "clear"; + break; + case "OF": + keyName = "end"; + break; + case "OH": + keyName = "home"; + break; + + /* xterm/rxvt ESC [ number ~ */ + case "[1~": + keyName = "home"; + break; + case "[2~": + keyName = "insert"; + break; + case "[3~": + keyName = "delete"; + break; + case "[4~": + keyName = "end"; + break; + case "[5~": + keyName = "pageup"; + break; + case "[6~": + keyName = "pagedown"; + break; + + /* putty */ + case "[[5~": + keyName = "pageup"; + break; + case "[[6~": + keyName = "pagedown"; + break; + + /* rxvt */ + case "[7~": + keyName = "home"; + break; + case "[8~": + keyName = "end"; + break; + + /* rxvt keys with modifiers */ + case "[a": + keyName = "up"; + keyShift = true; + break; + case "[b": + keyName = "down"; + keyShift = true; + break; + case "[c": + keyName = "right"; + keyShift = true; + break; + case "[d": + keyName = "left"; + keyShift = true; + break; + case "[e": + keyName = "clear"; + keyShift = true; + break; + + case "[2$": + keyName = "insert"; + keyShift = true; + break; + case "[3$": + keyName = "delete"; + keyShift = true; + break; + case "[5$": + keyName = "pageup"; + keyShift = true; + break; + case "[6$": + keyName = "pagedown"; + keyShift = true; + break; + case "[7$": + keyName = "home"; + keyShift = true; + break; + case "[8$": + keyName = "end"; + keyShift = true; + break; + + case "Oa": + keyName = "up"; + keyCtrl = true; + break; + case "Ob": + keyName = "down"; + keyCtrl = true; + break; + case "Oc": + keyName = "right"; + keyCtrl = true; + break; + case "Od": + keyName = "left"; + keyCtrl = true; + break; + case "Oe": + keyName = "clear"; + keyCtrl = true; + break; + + case "[2^": + keyName = "insert"; + keyCtrl = true; + break; + case "[3^": + keyName = "delete"; + keyCtrl = true; + break; + case "[5^": + keyName = "pageup"; + keyCtrl = true; + break; + case "[6^": + keyName = "pagedown"; + keyCtrl = true; + break; + case "[7^": + keyName = "home"; + keyCtrl = true; + break; + case "[8^": + keyName = "end"; + keyCtrl = true; + break; + + /* misc. */ + case "[Z": + keyName = "tab"; + keyShift = true; + break; + default: + keyName = "undefined"; + break; + } + } else if (ch === "\r") { + // carriage return + keyName = "return"; + keyMeta = escaped; + } else if (ch === "\n") { + // Enter, should have been called linefeed + keyName = "enter"; + keyMeta = escaped; + } else if (ch === "\t") { + // tab + keyName = "tab"; + keyMeta = escaped; + } else if (ch === "\b" || ch === "\x7f") { + // backspace or ctrl+h + keyName = "backspace"; + keyMeta = escaped; + } else if (ch === kEscape) { + // escape key + keyName = "escape"; + keyMeta = escaped; + } else if (ch === " ") { + keyName = "space"; + keyMeta = escaped; + } else if (!escaped && ch <= "\x1a") { + // ctrl+letter + keyName = StringFromCharCode( + StringPrototypeCharCodeAt.call(ch) + + StringPrototypeCharCodeAt.call("a") - + 1, + ); + keyCtrl = true; + } else if (RegExpPrototypeExec.call(/^[0-9A-Za-z]$/, ch) !== null) { + // Letter, number, shift+letter + keyName = StringPrototypeToLowerCase.call(ch); + keyShift = RegExpPrototypeExec.call(/^[A-Z]$/, ch) !== null; + keyMeta = escaped; + } else if (escaped) { + // Escape sequence timeout + keyName = ch.length ? undefined : "escape"; + keyMeta = true; + } + + keySeq = s; + + if (s.length !== 0 && (keyName !== undefined || escaped)) { + /* Named character or sequence */ + stream.emit("keypress", escaped ? undefined : s, { + sequence: keySeq, + name: keyName, + ctrl: keyCtrl, + meta: keyMeta, + shift: keyShift, + }); + } else if (charLengthAt(s, 0) === s.length) { + /* Single unnamed character, e.g. "." */ + stream.emit("keypress", s, { + sequence: keySeq, + name: keyName, + ctrl: keyCtrl, + meta: keyMeta, + shift: keyShift, + }); + } + /* 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]; + } + var sorted = ArrayPrototypeSort.call(ArrayPrototypeSlice.call(strings)); + var min = sorted[0]; + var max = sorted[sorted.length - 1]; + for (var i = 0; i < min.length; i++) { + if (min[i] !== max[i]) { + return StringPrototypeSlice.call(min, 0, i); + } + } + return min; +} + +// ---------------------------------------------------------------------------- +// Section: Cursor Functions +// ---------------------------------------------------------------------------- + +/** + * 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 (NumberIsNaN(x)) throw new ERR_INVALID_ARG_VALUE("x", x); + if (NumberIsNaN(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(); + + var 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; + } + + var 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; + } + + var 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); +} + +// ---------------------------------------------------------------------------- +// Section: Emit keypress events +// ---------------------------------------------------------------------------- + +var KEYPRESS_DECODER = Symbol("keypress-decoder"); +var ESCAPE_DECODER = Symbol("escape-decoder"); + +// GNU readline library - keyseq-timeout is 500ms (default) +var 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(); + + var triggerEscape = () => stream[ESCAPE_DECODER].next(""); + var { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface; + var timeoutId; + + function onData(input) { + if (stream.listenerCount("keypress") > 0) { + var 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; + + var length = 0; + for (var character of new SafeStringIterator(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); + } +} + +// ---------------------------------------------------------------------------- +// Section: Interface +// ---------------------------------------------------------------------------- + +var kEmptyObject = ObjectFreeze(ObjectCreate(null)); + +// Some constants regarding configuration of interface +var kHistorySize = 30; +var kMaxUndoRedoStackSize = 2048; +var kMincrlfDelay = 100; +// \r\n, \n, or \r followed by something other than \n +var lineEnding = /\r?\n|\r(?!\n)/g; + +// Max length of the kill ring +var kMaxLengthOfKillRing = 32; + +// Symbols + +// Public symbols +var kLineObjectStream = Symbol("line object stream"); +var kQuestionCancel = Symbol("kQuestionCancel"); +var kQuestion = Symbol("kQuestion"); + +// Private symbols +var kAddHistory = Symbol("_addHistory"); +var kBeforeEdit = Symbol("_beforeEdit"); +var kDecoder = Symbol("_decoder"); +var kDeleteLeft = Symbol("_deleteLeft"); +var kDeleteLineLeft = Symbol("_deleteLineLeft"); +var kDeleteLineRight = Symbol("_deleteLineRight"); +var kDeleteRight = Symbol("_deleteRight"); +var kDeleteWordLeft = Symbol("_deleteWordLeft"); +var kDeleteWordRight = Symbol("_deleteWordRight"); +var kGetDisplayPos = Symbol("_getDisplayPos"); +var kHistoryNext = Symbol("_historyNext"); +var kHistoryPrev = Symbol("_historyPrev"); +var kInsertString = Symbol("_insertString"); +var kLine = Symbol("_line"); +var kLine_buffer = Symbol("_line_buffer"); +var kKillRing = Symbol("_killRing"); +var kKillRingCursor = Symbol("_killRingCursor"); +var kMoveCursor = Symbol("_moveCursor"); +var kNormalWrite = Symbol("_normalWrite"); +var kOldPrompt = Symbol("_oldPrompt"); +var kOnLine = Symbol("_onLine"); +var kPreviousKey = Symbol("_previousKey"); +var kPrompt = Symbol("_prompt"); +var kPushToKillRing = Symbol("_pushToKillRing"); +var kPushToUndoStack = Symbol("_pushToUndoStack"); +var kQuestionCallback = Symbol("_questionCallback"); +var kRedo = Symbol("_redo"); +var kRedoStack = Symbol("_redoStack"); +var kRefreshLine = Symbol("_refreshLine"); +var kSawKeyPress = Symbol("_sawKeyPress"); +var kSawReturnAt = Symbol("_sawReturnAt"); +var kSetRawMode = Symbol("_setRawMode"); +var kTabComplete = Symbol("_tabComplete"); +var kTabCompleter = Symbol("_tabCompleter"); +var kTtyWrite = Symbol("_ttyWrite"); +var kUndo = Symbol("_undo"); +var kUndoStack = Symbol("_undoStack"); +var kWordLeft = Symbol("_wordLeft"); +var kWordRight = Symbol("_wordRight"); +var kWriteToOutput = Symbol("_writeToOutput"); +var kYank = Symbol("_yank"); +var kYanking = Symbol("_yanking"); +var kYankPop = Symbol("_yankPop"); + +// Event symbols +var kFirstEventParam = Symbol("nodejs.kFirstEventParam"); + +// class InterfaceConstructor extends EventEmitter { +// #onSelfCloseWithTerminal; +// #onSelfCloseWithoutTerminal; + +// #onError; +// #onData; +// #onEnd; +// #onTermEnd; +// #onKeyPress; +// #onResize; + +// [kSawReturnAt]; +// isCompletionEnabled = true; +// [kSawKeyPress]; +// [kPreviousKey]; +// escapeCodeTimeout; +// tabSize; + +// line; +// [kSubstringSearch]; +// output; +// input; +// [kUndoStack]; +// [kRedoStack]; +// history; +// historySize; + +// [kKillRing]; +// [kKillRingCursor]; + +// removeHistoryDuplicates; +// crlfDelay; +// completer; + +// terminal; +// [kLineObjectStream]; + +// cursor; +// historyIndex; + +// constructor(input, output, completer, terminal) { +// super(); + +var kOnSelfCloseWithTerminal = Symbol("_onSelfCloseWithTerminal"); +var kOnSelfCloseWithoutTerminal = Symbol("_onSelfCloseWithoutTerminal"); +var kOnKeyPress = Symbol("_onKeyPress"); +var kOnError = Symbol("_onError"); +var kOnData = Symbol("_onData"); +var kOnEnd = Symbol("_onEnd"); +var kOnTermEnd = Symbol("_onTermEnd"); +var kOnResize = Symbol("_onResize"); + +function onSelfCloseWithTerminal() { + var input = this.input; + var output = this.output; + + if (!input) throw new Error("Input not set, invalid state for readline!"); + + input.removeListener("keypress", this[kOnKeyPress]); + input.removeListener("error", this[kOnError]); + input.removeListener("end", this[kOnTermEnd]); + if (output !== null && output !== undefined) { + output.removeListener("resize", this[kOnResize]); + } +} + +function onSelfCloseWithoutTerminal() { + var input = this.input; + if (!input) throw new Error("Input not set, invalid state for readline!"); + + input.removeListener("data", this[kOnData]); + input.removeListener("error", this[kOnError]); + input.removeListener("end", this[kOnEnd]); +} + +function onError(err) { + this.emit("error", err); +} + +function onData(data) { + debug("onData"); + this[kNormalWrite](data); +} + +function onEnd() { + debug("onEnd"); + if (typeof this[kLine_buffer] === "string" && this[kLine_buffer].length > 0) { + this.emit("line", this[kLine_buffer]); + } + this.close(); +} + +function onTermEnd() { + debug("onTermEnd"); + if (typeof this.line === "string" && this.line.length > 0) { + this.emit("line", this.line); + } + this.close(); +} + +function onKeyPress(s, key) { + this[kTtyWrite](s, key); + if (key && key.sequence) { + // If the keySeq is half of a surrogate pair + // (>= 0xd800 and <= 0xdfff), refresh the line so + // the character is displayed appropriately. + var ch = StringPrototypeCodePointAt.call(key.sequence, 0); + if (ch >= 0xd800 && ch <= 0xdfff) this[kRefreshLine](); + } +} + +function onResize() { + this[kRefreshLine](); +} + +function InterfaceConstructor(input, output, completer, terminal) { + if (!(this instanceof InterfaceConstructor)) { + return new InterfaceConstructor(input, output, completer, terminal); + } + + EventEmitter.call(this); + + this[kOnSelfCloseWithoutTerminal] = onSelfCloseWithoutTerminal.bind(this); + this[kOnSelfCloseWithTerminal] = onSelfCloseWithTerminal.bind(this); + + this[kOnError] = onError.bind(this); + this[kOnData] = onData.bind(this); + this[kOnEnd] = onEnd.bind(this); + this[kOnTermEnd] = onTermEnd.bind(this); + this[kOnKeyPress] = onKeyPress.bind(this); + this[kOnResize] = onResize.bind(this); + + this[kSawReturnAt] = 0; + this.isCompletionEnabled = true; + this[kSawKeyPress] = false; + this[kPreviousKey] = null; + this.escapeCodeTimeout = ESCAPE_CODE_TIMEOUT; + this.tabSize = 8; + + var history; + var historySize; + var removeHistoryDuplicates = false; + var crlfDelay; + var prompt = "> "; + var 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; + + var tabSize = input.tabSize; + if (tabSize !== undefined) { + validateUint32(tabSize, "tabSize", true); + this.tabSize = tabSize; + } + removeHistoryDuplicates = input.removeHistoryDuplicates; + + var inputPrompt = input.prompt; + if (inputPrompt !== undefined) { + prompt = inputPrompt; + } + + var inputEscapeCodeTimeout = input.escapeCodeTimeout; + if (inputEscapeCodeTimeout !== undefined) { + if (NumberIsFinite(inputEscapeCodeTimeout)) { + this.escapeCodeTimeout = inputEscapeCodeTimeout; + } 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" || + NumberIsNaN(historySize) || + historySize < 0 + ) { + throw new ERR_INVALID_ARG_VALUE("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; + } + + 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 + ? MathMax(kMincrlfDelay, crlfDelay) + : kMincrlfDelay; + this.completer = completer; + + this.setPrompt(prompt); + + this.terminal = !!terminal; + + this[kLineObjectStream] = undefined; + + input.on("error", this[kOnError]); + + if (!this.terminal) { + input.on("data", this[kOnData]); + input.on("end", this[kOnEnd]); + this.once("close", this[kOnSelfCloseWithoutTerminal]); + this[kDecoder] = new StringDecoder("utf8"); + } else { + emitKeypressEvents(input, this); + + // `input` usually refers to stdin + input.on("keypress", this[kOnKeyPress]); + input.on("end", this[kOnTermEnd]); + + 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", this[kOnResize]); + + this.once("close", this[kOnSelfCloseWithTerminal]); + } + + if (signal) { + var onAborted = (() => this.close()).bind(this); + if (signal.aborted) { + process.nextTick(onAborted); + } else { + signal.addEventListener("abort", onAborted, { once: true }); + this.once("close", () => signal.removeEventListener("abort", onAborted)); + } + } + + // Current line + this.line = ""; + + input.resume(); +} + +ObjectSetPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype); +ObjectSetPrototypeOf(InterfaceConstructor, EventEmitter); + +var _Interface = class Interface extends InterfaceConstructor { + // TODO: Enumerate all the properties of the class + + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + get columns() { + var output = this.output; + if (output && output.columns) return 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) { + var input = this.input; + var { setRawMode, wasInRawMode } = input; + + // TODO: Make this work, for now just stub this and print debug + debug("setRawMode", mode, "set!"); + // if (typeof setRawMode === "function") { + // 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]); + } + } + + [kQuestion](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]) { + var 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 (StringPrototypeTrim.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 + var dupIndex = ArrayPrototypeIndexOf.call(this.history, this.line); + if (dupIndex !== -1) + ArrayPrototypeSplice.call(this.history, dupIndex, 1); + } + + ArrayPrototypeUnshift.call(this.history, this.line); + + // Only store so many + if (this.history.length > this.historySize) + ArrayPrototypePop.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 + var line = this.history[0]; + + // Emit history event to notify listeners of update + this.emit("history", this.history); + + return line; + } + + [kRefreshLine]() { + // line length + var line = this[kPrompt] + this.line; + var dispPos = this[kGetDisplayPos](line); + var lineCols = dispPos.cols; + var lineRows = dispPos.rows; + + // cursor position + var cursorPos = this.getCursorPos(); + + // First move to the bottom of the current line, based on cursor pos + var 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); + + var 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; + } + var string = this[kDecoder].write(b); + if ( + this[kSawReturnAt] && + DateNow() - this[kSawReturnAt] <= this.crlfDelay + ) { + if (StringPrototypeCodePointAt.call(string) === 10) + string = StringPrototypeSlice.call(string, 1); + this[kSawReturnAt] = 0; + } + + // Run test() on the new string chunk, not on the entire line buffer. + var newPartContainsEnding = RegExpPrototypeExec.call(lineEnding, string); + if (newPartContainsEnding !== null) { + if (this[kLine_buffer]) { + string = this[kLine_buffer] + string; + this[kLine_buffer] = null; + newPartContainsEnding = RegExpPrototypeExec.call(lineEnding, string); + } + this[kSawReturnAt] = StringPrototypeEndsWith.call(string, "\r") + ? DateNow() + : 0; + + var indexes = [0, newPartContainsEnding.index, lineEnding.lastIndex]; + var nextMatch; + while ( + (nextMatch = RegExpPrototypeExec.call(lineEnding, string)) !== null + ) { + ArrayPrototypePush.call(indexes, nextMatch.index, lineEnding.lastIndex); + } + var lastIndex = indexes.length - 1; + // Either '' or (conceivably) the unfinished portion of the next line + this[kLine_buffer] = StringPrototypeSlice.call( + string, + indexes[lastIndex], + ); + for (var i = 1; i < lastIndex; i += 2) { + this[kOnLine]( + StringPrototypeSlice.call(string, indexes[i - 1], indexes[i]), + ); + } + } else if (string) { + // No newlines this time, save what we have for next time + if (this[kLine_buffer]) { + this[kLine_buffer] += string; + } else { + this[kLine_buffer] = string; + } + } + } + + [kInsertString](c) { + this[kBeforeEdit](this.line, this.cursor); + if (this.cursor < this.line.length) { + var beg = StringPrototypeSlice.call(this.line, 0, this.cursor); + var end = StringPrototypeSlice.call( + this.line, + this.cursor, + this.line.length, + ); + this.line = beg + c + end; + this.cursor += c.length; + this[kRefreshLine](); + } else { + var oldPos = this.getCursorPos(); + this.line += c; + this.cursor += c.length; + var newPos = this.getCursorPos(); + + if (oldPos.rows < newPos.rows) { + this[kRefreshLine](); + } else { + this[kWriteToOutput](c); + } + } + } + + async [kTabComplete](lastKeypressWasTab) { + this.pause(); + var string = StringPrototypeSlice.call(this.line, 0, this.cursor); + var 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. + var prefix = commonPrefix( + ArrayPrototypeFilter.call(completions, (e) => e !== ""), + ); + if ( + StringPrototypeStartsWith.call(prefix, completeOn) && + prefix.length > completeOn.length + ) { + this[kInsertString](StringPrototypeSlice.call(prefix, completeOn.length)); + return; + } else if (!StringPrototypeStartsWith.call(completeOn, prefix)) { + this.line = + StringPrototypeSlice.call( + this.line, + 0, + this.cursor - completeOn.length, + ) + + prefix + + StringPrototypeSlice.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. + var completionsWidth = ArrayPrototypeMap.call(completions, (e) => + getStringWidth(e), + ); + var width = MathMaxApply(completionsWidth) + 2; // 2 space padding + var maxColumns = MathFloor(this.columns / width) || 1; + if (maxColumns === Infinity) { + maxColumns = 1; + } + var output = "\r\n"; + var lineIndex = 0; + var whitespace = 0; + for (var i = 0; i < completions.length; i++) { + var completion = completions[i]; + if (completion === "" || lineIndex === maxColumns) { + output += "\r\n"; + lineIndex = 0; + whitespace = 0; + } else { + output += StringPrototypeRepeat.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 + var leading = StringPrototypeSlice.call(this.line, 0, this.cursor); + var reversed = ArrayPrototypeJoin.call( + ArrayPrototypeReverse.call(ArrayFrom(leading)), + "", + ); + var match = RegExpPrototypeExec.call(/^\s*(?:[^\w\s]+|\w+)?/, reversed); + this[kMoveCursor](-match[0].length); + } + } + + [kWordRight]() { + if (this.cursor < this.line.length) { + var trailing = StringPrototypeSlice.call(this.line, this.cursor); + var match = RegExpPrototypeExec.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 + var charSize = charLengthLeft(this.line, this.cursor); + this.line = + StringPrototypeSlice.call(this.line, 0, this.cursor - charSize) + + StringPrototypeSlice.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 + var charSize = charLengthAt(this.line, this.cursor); + this.line = + StringPrototypeSlice.call(this.line, 0, this.cursor) + + StringPrototypeSlice.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 + var leading = StringPrototypeSlice.call(this.line, 0, this.cursor); + var reversed = ArrayPrototypeJoin.call( + ArrayPrototypeReverse.call(ArrayFrom(leading)), + "", + ); + var match = RegExpPrototypeExec.call(/^\s*(?:[^\w\s]+|\w+)?/, reversed); + leading = StringPrototypeSlice.call( + leading, + 0, + leading.length - match[0].length, + ); + this.line = + leading + + StringPrototypeSlice.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); + var trailing = StringPrototypeSlice.call(this.line, this.cursor); + var match = RegExpPrototypeExec.call(/^(?:\s+|\W+|\w+)\s*/, trailing); + this.line = + StringPrototypeSlice.call(this.line, 0, this.cursor) + + StringPrototypeSlice.call(trailing, match[0].length); + this[kRefreshLine](); + } + } + + [kDeleteLineLeft]() { + this[kBeforeEdit](this.line, this.cursor); + var del = StringPrototypeSlice.call(this.line, 0, this.cursor); + this.line = StringPrototypeSlice.call(this.line, this.cursor); + this.cursor = 0; + this[kPushToKillRing](del); + this[kRefreshLine](); + } + + [kDeleteLineRight]() { + this[kBeforeEdit](this.line, this.cursor); + var del = StringPrototypeSlice.call(this.line, this.cursor); + this.line = StringPrototypeSlice.call(this.line, 0, this.cursor); + this[kPushToKillRing](del); + this[kRefreshLine](); + } + + [kPushToKillRing](del) { + if (!del || del === this[kKillRing][0]) return; + ArrayPrototypeUnshift.call(this[kKillRing], del); + this[kKillRingCursor] = 0; + while (this[kKillRing].length > kMaxLengthOfKillRing) + ArrayPrototypePop.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) { + var lastYank = this[kKillRing][this[kKillRingCursor]]; + this[kKillRingCursor]++; + if (this[kKillRingCursor] >= this[kKillRing].length) { + this[kKillRingCursor] = 0; + } + var currentYank = this[kKillRing][this[kKillRingCursor]]; + var head = StringPrototypeSlice.call( + this.line, + 0, + this.cursor - lastYank.length, + ); + var tail = StringPrototypeSlice.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]() { + var line = this[kAddHistory](); + this[kUndoStack] = []; + this[kRedoStack] = []; + this.clearLine(); + this[kOnLine](line); + } + + [kPushToUndoStack](text, cursor) { + if ( + ArrayPrototypePush.call(this[kUndoStack], { text, cursor }) > + kMaxUndoRedoStackSize + ) { + ArrayPrototypeShift.call(this[kUndoStack]); + } + } + + [kUndo]() { + if (this[kUndoStack].length <= 0) return; + + ArrayPrototypePush.call(this[kRedoStack], { + text: this.line, + cursor: this.cursor, + }); + + var entry = ArrayPrototypePop.call(this[kUndoStack]); + this.line = entry.text; + this.cursor = entry.cursor; + + this[kRefreshLine](); + } + + [kRedo]() { + if (this[kRedoStack].length <= 0) return; + + ArrayPrototypePush.call(this[kUndoStack], { + text: this.line, + cursor: this.cursor, + }); + + var entry = ArrayPrototypePop.call(this[kRedoStack]); + this.line = entry.text; + this.cursor = entry.cursor; + + this[kRefreshLine](); + } + + [kHistoryNext]() { + if (this.historyIndex >= 0) { + this[kBeforeEdit](this.line, this.cursor); + var search = this[kSubstringSearch] || ""; + var index = this.historyIndex - 1; + while ( + index >= 0 && + (!StringPrototypeStartsWith.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); + var search = this[kSubstringSearch] || ""; + var index = this.historyIndex + 1; + while ( + index < this.history.length && + (!StringPrototypeStartsWith.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) { + var offset = 0; + var col = this.columns; + var rows = 0; + str = stripVTControlCharacters(str); + for (var char of new SafeStringIterator(str)) { + if (char === "\n") { + // Rows must be incremented by 1 even if offset = 0 or col = +Infinity. + rows += MathCeil(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; + } + var width = getStringWidth(char, false /* stripVTControlCharacters */); + if (width === 0 || width === 1) { + offset += width; + } else { + // width === 2 + if ((offset + 1) % col === 0) { + offset++; + } + offset += 2; + } + } + var 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() { + var strBeforeCursor = + this[kPrompt] + StringPrototypeSlice.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; + } + var 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; + } + + var newPos = this.getCursorPos(); + + // Check if cursor stayed on the line. + if (oldPos.rows === newPos.rows) { + var diffWidth = newPos.cols - oldPos.cols; + moveCursor(this.output, diffWidth, 0); + } else { + this[kRefreshLine](); + } + } + + // Handle a write from the tty + [kTtyWrite](s, key) { + var previousKey = this[kPreviousKey]; + key = key || kEmptyObject; + this[kPreviousKey] = key; + var { name: keyName, meta: keyMeta, ctrl: keyCtrl, shift: keyShift } = key; + + if (!keyMeta || keyName !== "y") { + // Reset yanking state unless we are doing yank pop. + this[kYanking] = false; + } + + // Activate or deactivate substring search. + if ( + (keyName === "up" || keyName === "down") && + !keyCtrl && + !keyMeta && + !keyShift + ) { + if (this[kSubstringSearch] === null) { + this[kSubstringSearch] = StringPrototypeSlice.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 keySeq === "string") { + switch (StringPrototypeCodePointAt.call(keySeq, 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 (keyName === "escape") return; + + if (keyCtrl && keyShift) { + /* Control and shift pressed */ + switch (keyName) { + // TODO(BridgeAR): The transmitted escape sequence is `\b` and that is + // identical to <ctrl>-h. It should have a unique escape sequence. + case "backspace": + this[kDeleteLineLeft](); + break; + + case "delete": + this[kDeleteLineRight](); + break; + } + } else if (keyCtrl) { + /* Control key pressed */ + + switch (keyName) { + 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 + 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 (keyMeta) { + /* Meta key pressed */ + + switch (keyName) { + 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] && keyName !== "enter") this[kSawReturnAt] = 0; + + switch (keyName) { + case "return": // Carriage return, i.e. \r + this[kSawReturnAt] = DateNow(); + this[kLine](); + break; + + case "enter": + // When key interval > crlfDelay + if ( + this[kSawReturnAt] === 0 || + DateNow() - 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 + ) { + var lastKeypressWasTab = previousKey && previousKey.name === "tab"; + this[kTabComplete](lastKeypressWasTab); + break; + } + // falls through + default: + if (typeof s === "string" && s) { + var nextMatch = RegExpPrototypeExec.call(lineEnding, s); + if (nextMatch !== null) { + this[kInsertString]( + StringPrototypeSlice.call(s, 0, nextMatch.index), + ); + var { lastIndex } = lineEnding; + while ( + (nextMatch = RegExpPrototypeExec.call(lineEnding, s)) !== null + ) { + this[kLine](); + this[kInsertString]( + StringPrototypeSlice.call(s, lastIndex, nextMatch.index), + ); + ({ lastIndex } = lineEnding); + } + if (lastIndex === s.length) this[kLine](); + } else { + this[kInsertString](s); + } + } + } + } + } + + /** + * Creates an `AsyncIterator` object that iterates through + * each line in the input stream as a string. + * @typedef {{ + * [Symbol.asyncIterator]: () => InterfaceAsyncIterator, + * next: () => Promise<string> + * }} InterfaceAsyncIterator + * @returns {InterfaceAsyncIterator} + */ + [SymbolAsyncIterator]() { + if (this[kLineObjectStream] === undefined) { + this[kLineObjectStream] = EventEmitter.on(this, "line", { + close: ["close"], + highWatermark: 1024, + [kFirstEventParam]: true, + }); + } + return this[kLineObjectStream]; + } +}; + +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 + ) { + var { completer } = input; + input.completer = (v, cb) => cb(null, completer(v)); + } else if (typeof completer === "function" && completer.length !== 2) { + var realCompleter = completer; + completer = (v, cb) => cb(null, realCompleter(v)); + } + + InterfaceConstructor.call(this, input, output, completer, terminal); + + // TODO: Test this + if (process.env.TERM === "dumb") { + this._ttyWrite = _ttyWriteDumb.bind(this); + } +} + +ObjectSetPrototypeOf(Interface.prototype, _Interface.prototype); +ObjectSetPrototypeOf(Interface, _Interface); + +/** + * Displays `query` by writing it to the `output`. + * @param {string} query + * @param {{ signal?: AbortSignal; }} [options] + * @param {Function} cb + * @returns {void} + */ +Interface.prototype.question = function question(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; + } + + var onAbort = () => { + this[kQuestionCancel](); + }; + options.signal.addEventListener("abort", onAbort, { once: true }); + var cleanup = () => { + options.signal.removeEventListener("abort", onAbort); + }; + var originalCb = cb; + cb = + typeof cb === "function" + ? (answer) => { + cleanup(); + return originalCb(answer); + } + : cleanup; + } + + if (typeof cb === "function") { + this[kQuestion](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 PromiseReject( + new AbortError(undefined, { cause: options.signal.reason }), + ); + } + + return new Promise((resolve, reject) => { + var cb = resolve; + + if (options.signal) { + var 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); +} + +ObjectDefineProperties(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(); + var string = StringPrototypeSlice.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 (keyCtrl) { + 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); + } + } +} + +// ---------------------------------------------------------------------------- +// Exports +// ---------------------------------------------------------------------------- +export var Interface = Interface; +export var clearLine = clearLine; +export var clearScreenDown = clearScreenDown; +export var createInterface = createInterface; +export var cursorTo = cursorTo; +export var emitKeypressEvents = emitKeypressEvents; +export var moveCursor = moveCursor; +export var promises = { + [SymbolFor("__UNIMPLEMENTED__")]: true, +}; + +export default { + Interface, + clearLine, + clearScreenDown, + createInterface, + cursorTo, + emitKeypressEvents, + moveCursor, + promises, + + [SymbolFor("__BUN_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED__")]: { + CSI, + _Interface, + utils: { + getStringWidth, + stripVTControlCharacters, + }, + shared: { + kEmptyObject, + validateBoolean, + validateInteger, + validateAbortSignal, + ERR_INVALID_ARG_TYPE, + }, + symbols: { + kQuestion, + kQuestionCancel, + }, + }, + [SymbolFor("CommonJS")]: 0, +}; diff --git a/src/bun.js/readline_promises.exports.js b/src/bun.js/readline_promises.exports.js new file mode 100644 index 000000000..615b69ec7 --- /dev/null +++ b/src/bun.js/readline_promises.exports.js @@ -0,0 +1,218 @@ +// Attribution: Some parts of of this module are derived from code originating from the Node.js +// readline module which is licensed under an MIT license: +// +// Copyright Node.js contributors. All rights reserved. +// +// 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. + +var { Promise } = import.meta.primordials; +var readline = import.meta.require("node:readline"); +var isWritable; + +var ArrayPrototypePush = Array.prototype.push; +var ArrayPrototypeJoin = Array.prototype.join; +var SymbolFor = Symbol.for; +var kInternal = SymbolFor("__BUN_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED__"); + +var { + CSI, + _Interface, + symbols: { kQuestion, kQuestionCancel }, + shared: { + kEmptyObject, + validateAbortSignal, + validateBoolean, + validateInteger, + ERR_INVALID_ARG_TYPE, + }, +} = readline[kInternal]; + +var { kClearToLineBeginning, kClearToLineEnd, kClearLine, kClearScreenDown } = + CSI; + +class AbortError extends Error { + code; + constructor() { + super("The operation was aborted"); + this.code = "ABORT_ERR"; + } +} + +export class Readline { + #autoCommit = false; + #stream; + #todo = []; + + constructor(stream, options = undefined) { + isWritable ??= import.meta.require("node:stream").isWritable; + 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"); + + var data = y == null ? CSI`${x + 1}G` : CSI`${y + 1};${x + 1}H`; + if (this.#autoCommit) process.nextTick(() => this.#stream.write(data)); + else ArrayPrototypePush.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"); + + var 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 ArrayPrototypePush.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); + + var data = + dir < 0 ? kClearToLineBeginning : dir > 0 ? kClearToLineEnd : kClearLine; + if (this.#autoCommit) process.nextTick(() => this.#stream.write(data)); + else ArrayPrototypePush.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 { + ArrayPrototypePush.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<void>} Resolves when all pending actions have been + * flushed to the associated `stream`. + */ + commit() { + return new Promise((resolve) => { + this.#stream.write(ArrayPrototypeJoin.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 class Interface extends _Interface { + // eslint-disable-next-line no-useless-constructor + constructor(input, output, completer, terminal) { + super(input, output, completer, terminal); + } + question(query, options = kEmptyObject) { + return new Promise((resolve, reject) => { + var cb = resolve; + + if (options?.signal) { + validateAbortSignal(options.signal, "options.signal"); + if (options.signal.aborted) { + return reject( + new AbortError(undefined, { cause: options.signal.reason }), + ); + } + + var onAbort = () => { + this[kQuestionCancel](); + 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[kQuestion](query, cb); + }); + } +} + +export function createInterface(input, output, completer, terminal) { + return new Interface(input, output, completer, terminal); +} + +export default { + Readline, + Interface, + createInterface, + + [SymbolFor("CommonJS")]: 0, +}; diff --git a/src/bun.js/streams.exports.js b/src/bun.js/streams.exports.js index 6f03e23eb..d5e8a2183 100644 --- a/src/bun.js/streams.exports.js +++ b/src/bun.js/streams.exports.js @@ -5014,7 +5014,7 @@ var require_transform = __commonJS({ if (typeof options.flush === "function") this._flush = options.flush; } - this.on("prefinish", prefinish); + this.on("prefinish", prefinish.bind(this)); } ObjectSetPrototypeOf(Transform.prototype, Duplex.prototype); ObjectSetPrototypeOf(Transform, Duplex); diff --git a/test/bun.js/node-test-helpers.js b/test/bun.js/node-test-helpers.js deleted file mode 100644 index f62f1ab3b..000000000 --- a/test/bun.js/node-test-helpers.js +++ /dev/null @@ -1,156 +0,0 @@ -import { expect as expect_ } from "bun:test"; -import { gcTick } from "gc"; -import assertNode from "node:assert"; - -const expect = (actual) => { - gcTick(); - const ret = expect_(actual); - gcTick(); - return ret; -}; - -export const strictEqual = (...args) => { - let error = null; - try { - assertNode.strictEqual(...args); - } catch (err) { - error = err; - } - expect(error).toBe(null); -}; - -export const throws = (...args) => { - let error = null; - try { - assertNode.throws(...args); - } catch (err) { - error = err; - } - expect(error).toBe(null); -}; - -export const assert = (...args) => { - let error = null; - try { - assertNode(...args); - } catch (err) { - error = err; - } - expect(error).toBe(null); -}; - -export const assertOk = (...args) => { - let error = null; - try { - assertNode.ok(...args); - } catch (err) { - error = err; - } - expect(error).toBe(null); -}; - -export const createCallCheckCtx = (done, timeout = 1500) => { - const createDone = createDoneDotAll(done); - // const mustCallChecks = []; - - // failed.forEach(function (context) { - // console.log( - // "Mismatched %s function calls. Expected %s, actual %d.", - // context.name, - // context.messageSegment, - // context.actual - // ); - // console.log(context.stack.split("\n").slice(2).join("\n")); - // }); - - // TODO: Implement this to be exact only - function mustCall(fn, exact) { - return mustCallAtLeast(fn, exact); - } - - function mustSucceed(fn, exact) { - return mustCall(function (err, ...args) { - assert.ifError(err); - if (typeof fn === "function") return fn.apply(this, args); - }, exact); - } - - function mustCallAtLeast(fn, minimum) { - return _mustCallInner(fn, minimum, "minimum"); - } - - function _mustCallInner(fn, criteria = 1, field) { - if (process._exiting) - throw new Error("Cannot use common.mustCall*() in process exit handler"); - if (typeof fn === "number") { - criteria = fn; - fn = noop; - } else if (fn === undefined) { - fn = noop; - } - - if (typeof criteria !== "number") - throw new TypeError(`Invalid ${field} value: ${criteria}`); - - let actual = 0; - let expected = criteria; - - // mustCallChecks.push(context); - const done = createDone(timeout); - const _return = (...args) => { - const result = fn.apply(this, args); - actual++; - if (actual >= expected) { - done(); - } - return result; - }; - // Function instances have own properties that may be relevant. - // Let's replicate those properties to the returned function. - // Refs: https://tc39.es/ecma262/#sec-function-instances - Object.defineProperties(_return, { - name: { - value: fn.name, - writable: false, - enumerable: false, - configurable: true, - }, - length: { - value: fn.length, - writable: false, - enumerable: false, - configurable: true, - }, - }); - return _return; - } - return { - mustSucceed, - mustCall, - mustCallAtLeast, - }; -}; - -export function createDoneDotAll(done) { - let toComplete = 0; - let completed = 0; - function createDoneCb(timeout) { - toComplete += 1; - const timer = setTimeout(() => { - console.log("Timeout"); - done(new Error("Timed out!")); - }, timeout); - return (result) => { - clearTimeout(timer); - if (result instanceof Error) { - done(result); - return; - } - completed += 1; - if (completed === toComplete) { - done(); - } - }; - } - return createDoneCb; -} diff --git a/test/bun.js/node-test-helpers.ts b/test/bun.js/node-test-helpers.ts new file mode 100644 index 000000000..ff7b8ece3 --- /dev/null +++ b/test/bun.js/node-test-helpers.ts @@ -0,0 +1,198 @@ +import { expect as expect_ } from "bun:test"; +// @ts-ignore +import { gcTick } from "gc"; +import assertNode from "node:assert"; + +type DoneCb = (err?: Error) => any; +function noop() {} + +const expect = (actual) => { + gcTick(); + const ret = expect_(actual); + gcTick(); + return ret; +}; + +// Assert +export const strictEqual = ( + ...args: Parameters<typeof assertNode.strictEqual> +) => { + assertNode.strictEqual.apply(this, args); + expect(true).toBe(true); +}; + +export const notStrictEqual = ( + ...args: Parameters<typeof assertNode.notStrictEqual> +) => { + assertNode.notStrictEqual.apply(this, args); + expect(true).toBe(true); +}; + +export const deepStrictEqual = ( + ...args: Parameters<typeof assertNode.deepStrictEqual> +) => { + assertNode.deepStrictEqual.apply(this, args); + expect(true).toBe(true); +}; + +export const throws = (...args: Parameters<typeof assertNode.throws>) => { + assertNode.throws.apply(this, args); + expect(true).toBe(true); +}; + +export const ok = (...args: Parameters<typeof assertNode.ok>) => { + assertNode.ok.apply(this, args); + expect(true).toBe(true); +}; + +export const ifError = (...args: Parameters<typeof assertNode.ifError>) => { + assertNode.ifError.apply(this, args); + expect(true).toBe(true); +}; + +export const match = (...args: Parameters<typeof assertNode.match>) => { + assertNode.match.apply(this, args); + expect(true).toBe(true); +}; + +export const assert = { + strictEqual, + deepStrictEqual, + notStrictEqual, + throws, + ok, + ifError, + match, +}; + +// End assert + +export const createCallCheckCtx = (done: DoneCb) => { + const createDone = createDoneDotAll(done); + + // const mustCallChecks = []; + + // failed.forEach(function (context) { + // console.log( + // "Mismatched %s function calls. Expected %s, actual %d.", + // context.name, + // context.messageSegment, + // context.actual + // ); + // console.log(context.stack.split("\n").slice(2).join("\n")); + // }); + + // TODO: Implement this to be exact only + function mustCall(fn?: (...args) => any, exact?: number) { + return mustCallAtLeast(fn, exact); + } + + function mustNotCall( + reason: string = "function should not have been called", + ) { + const localDone = createDone(); + setTimeout(() => localDone(), 200); + return () => { + done(new Error(reason)); + }; + } + + function mustSucceed(fn: () => any, exact?: number) { + return mustCall(function (err, ...args) { + ifError(err); + // @ts-ignore + if (typeof fn === "function") return fn.apply(this, args as []); + }, exact); + } + + function mustCallAtLeast(fn, minimum) { + return _mustCallInner(fn, minimum, "minimum"); + } + + function _mustCallInner(fn, criteria = 1, field) { + if (process._exiting) + throw new Error("Cannot use common.mustCall*() in process exit handler"); + if (typeof fn === "number") { + criteria = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + + if (typeof criteria !== "number") + throw new TypeError(`Invalid ${field} value: ${criteria}`); + + let actual = 0; + let expected = criteria; + + // mustCallChecks.push(context); + const done = createDone(); + const _return = (...args) => { + // @ts-ignore + const result = fn.apply(this, args); + actual++; + if (actual >= expected) { + done(); + } + return result; + }; + // Function instances have own properties that may be relevant. + // Let's replicate those properties to the returned function. + // Refs: https://tc39.es/ecma262/#sec-function-instances + Object.defineProperties(_return, { + name: { + value: fn.name, + writable: false, + enumerable: false, + configurable: true, + }, + length: { + value: fn.length, + writable: false, + enumerable: false, + configurable: true, + }, + }); + return _return; + } + return { + mustSucceed, + mustCall, + mustCallAtLeast, + mustNotCall, + }; +}; + +export function createDoneDotAll(done: DoneCb, globalTimeout?: number) { + let toComplete = 0; + let completed = 0; + const globalTimer = globalTimeout + ? setTimeout(() => { + console.log("Global Timeout"); + done(new Error("Timed out!")); + }, globalTimeout) + : undefined; + function createDoneCb(timeout?: number) { + toComplete += 1; + const timer = + timeout !== undefined + ? setTimeout(() => { + console.log("Timeout"); + done(new Error("Timed out!")); + }, timeout) + : timeout; + return (result?: Error) => { + if (timer) clearTimeout(timer); + if (globalTimer) clearTimeout(globalTimer); + if (result instanceof Error) { + done(result); + return; + } + completed += 1; + if (completed === toComplete) { + done(); + } + }; + } + return createDoneCb; +} diff --git a/test/bun.js/readline.node.test.ts b/test/bun.js/readline.node.test.ts new file mode 100644 index 000000000..c681a8e15 --- /dev/null +++ b/test/bun.js/readline.node.test.ts @@ -0,0 +1,2018 @@ +import { beforeEach, describe, it } from "bun:test"; +import readline from "node:readline"; +import { Writable, PassThrough } from "node:stream"; +import { EventEmitter } from "node:events"; +import { + createDoneDotAll, + createCallCheckCtx, + assert, +} from "./node-test-helpers"; + +var { + CSI, + utils: { getStringWidth, stripVTControlCharacters }, +} = readline[Symbol.for("__BUN_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED__")]; + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +class TestWritable extends Writable { + data; + constructor() { + super(); + this.data = ""; + } + _write(chunk, encoding, callback) { + this.data += chunk.toString(); + callback(); + } +} + +class FakeInput extends EventEmitter { + resume() {} + pause() {} + write() {} + end() {} +} + +function isWarned(emitter) { + for (const name in emitter) { + const listeners = emitter[name]; + if (listeners.warned) return true; + } + return false; +} + +function getInterface(options) { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + ...options, + }); + return [rli, fi]; +} + +function assertCursorRowsAndCols(rli, rows, cols) { + const cursorPos = rli.getCursorPos(); + assert.strictEqual(cursorPos.rows, rows); + assert.strictEqual(cursorPos.cols, cols); +} + +const writable = new TestWritable(); +const input = new FakeInput(); + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +describe("CSI", () => { + it("should be defined", () => { + assert.ok(CSI); + }); + + it("should have all the correct clear sequences", () => { + assert.strictEqual(CSI.kClearToLineBeginning, "\x1b[1K"); + assert.strictEqual(CSI.kClearToLineEnd, "\x1b[0K"); + assert.strictEqual(CSI.kClearLine, "\x1b[2K"); + assert.strictEqual(CSI.kClearScreenDown, "\x1b[0J"); + assert.strictEqual(CSI`1${2}3`, "\x1b[123"); + }); +}); + +describe("readline.clearScreenDown()", () => { + it("should put clear screen sequence into writable when called", (done) => { + const { mustCall } = createCallCheckCtx(done); + + assert.strictEqual(readline.clearScreenDown(writable), true); + assert.deepStrictEqual(writable.data, CSI.kClearScreenDown); + assert.strictEqual(readline.clearScreenDown(writable, mustCall()), true); + }); + + it("should throw on invalid callback", () => { + // Verify that clearScreenDown() throws on invalid callback. + assert.throws(() => { + readline.clearScreenDown(writable, null); + }, /ERR_INVALID_ARG_TYPE/); + }); + + it("should that clearScreenDown() does not throw on null or undefined stream", (done) => { + const { mustCall } = createCallCheckCtx(done); + assert.strictEqual( + readline.clearScreenDown( + null, + mustCall((err) => { + assert.strictEqual(err, null); + }), + ), + true, + ); + assert.strictEqual(readline.clearScreenDown(undefined, mustCall()), true); + }); +}); + +describe("readline.clearLine()", () => { + beforeEach(() => { + writable.data = ""; + }); + + it("should clear to the left of cursor when given -1 as direction", () => { + assert.strictEqual(readline.clearLine(writable, -1), true); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + }); + + it("should clear to the right of cursor when given 1 as direction", () => { + assert.strictEqual(readline.clearLine(writable, 1), true); + assert.deepStrictEqual(writable.data, CSI.kClearToLineEnd); + }); + + it("should clear whole line when given 0 as direction", () => { + assert.strictEqual(readline.clearLine(writable, 0), true); + assert.deepStrictEqual(writable.data, CSI.kClearLine); + }); + + it("should call callback after clearing line", (done) => { + const { mustCall } = createCallCheckCtx(done); + assert.strictEqual(readline.clearLine(writable, -1, mustCall()), true); + assert.deepStrictEqual(writable.data, CSI.kClearToLineBeginning); + }); + + it("should throw on an invalid callback", () => { + // Verify that clearLine() throws on invalid callback. + assert.throws(() => { + readline.clearLine(writable, 0, null); + }, /ERR_INVALID_ARG_TYPE/); + }); + + it("shouldn't throw on on null or undefined stream", (done) => { + const { mustCall } = createCallCheckCtx(done); + // Verify that clearLine() does not throw on null or undefined stream. + assert.strictEqual(readline.clearLine(null, 0), true); + assert.strictEqual(readline.clearLine(undefined, 0), true); + assert.strictEqual( + readline.clearLine( + null, + 0, + mustCall((err) => { + assert.strictEqual(err, null); + }), + ), + true, + ); + assert.strictEqual(readline.clearLine(undefined, 0, mustCall()), true); + }); +}); + +describe("readline.moveCursor()", () => { + it("shouldn't write when moveCursor(0, 0) is called", (done) => { + const { mustCall } = createCallCheckCtx(done); + // Nothing is written when moveCursor 0, 0 + [ + [0, 0, ""], + [1, 0, "\x1b[1C"], + [-1, 0, "\x1b[1D"], + [0, 1, "\x1b[1B"], + [0, -1, "\x1b[1A"], + [1, 1, "\x1b[1C\x1b[1B"], + [-1, 1, "\x1b[1D\x1b[1B"], + [-1, -1, "\x1b[1D\x1b[1A"], + [1, -1, "\x1b[1C\x1b[1A"], + ].forEach((set) => { + writable.data = ""; + assert.strictEqual(readline.moveCursor(writable, set[0], set[1]), true); + assert.deepStrictEqual(writable.data, set[2]); + writable.data = ""; + assert.strictEqual( + readline.moveCursor(writable, set[0], set[1], mustCall()), + true, + ); + assert.deepStrictEqual(writable.data, set[2]); + }); + }); + + it("should throw on invalid callback", () => { + // Verify that moveCursor() throws on invalid callback. + assert.throws(() => { + readline.moveCursor(writable, 1, 1, null); + }, /ERR_INVALID_ARG_TYPE/); + }); + + it("should not throw on null or undefined stream", (done) => { + const { mustCall } = createCallCheckCtx(done); + // Verify that moveCursor() does not throw on null or undefined stream. + assert.strictEqual(readline.moveCursor(null, 1, 1), true); + assert.strictEqual(readline.moveCursor(undefined, 1, 1), true); + assert.strictEqual( + readline.moveCursor( + null, + 1, + 1, + mustCall((err) => { + assert.strictEqual(err, null); + }), + ), + true, + ); + assert.strictEqual(readline.moveCursor(undefined, 1, 1, mustCall()), true); + }); +}); + +describe("readline.cursorTo()", () => { + beforeEach(() => { + writable.data = ""; + }); + + it("should not throw on undefined or null as stream", (done) => { + const { mustCall } = createCallCheckCtx(done); + // Undefined or null as stream should not throw. + assert.strictEqual(readline.cursorTo(null), true); + assert.strictEqual(readline.cursorTo(), true); + assert.strictEqual(readline.cursorTo(null, 1, 1, mustCall()), true); + assert.strictEqual( + readline.cursorTo( + undefined, + 1, + 1, + mustCall((err) => { + assert.strictEqual(err, null); + }), + ), + true, + ); + }); + + it("should not write if given invalid cursor position - [string, undefined]", () => { + assert.strictEqual(readline.cursorTo(writable, "a"), true); + assert.strictEqual(writable.data, ""); + }); + + it("should not write if given invalid cursor position - [string, string]", () => { + assert.strictEqual(readline.cursorTo(writable, "a", "b"), true); + assert.strictEqual(writable.data, ""); + }); + + it("should throw when x is not a number", () => { + assert.throws(() => readline.cursorTo(writable, "a", 1), { + name: "TypeError", + code: "ERR_INVALID_CURSOR_POS", + message: "Cannot set cursor row without setting its column", + }); + assert.strictEqual(writable.data, ""); + }); + + it("should write when given value cursor positions", (done) => { + const { mustCall } = createCallCheckCtx(done); + + assert.strictEqual(readline.cursorTo(writable, 1, "a"), true); + assert.strictEqual(writable.data, "\x1b[2G"); + + writable.data = ""; + assert.strictEqual(readline.cursorTo(writable, 1), true); + assert.strictEqual(writable.data, "\x1b[2G"); + + writable.data = ""; + assert.strictEqual(readline.cursorTo(writable, 1, 2), true); + assert.strictEqual(writable.data, "\x1b[3;2H"); + + writable.data = ""; + assert.strictEqual(readline.cursorTo(writable, 1, 2, mustCall()), true); + assert.strictEqual(writable.data, "\x1b[3;2H"); + + writable.data = ""; + assert.strictEqual(readline.cursorTo(writable, 1, mustCall()), true); + assert.strictEqual(writable.data, "\x1b[2G"); + }); + + it("should throw on invalid callback", () => { + // Verify that cursorTo() throws on invalid callback. + assert.throws(() => { + readline.cursorTo(writable, 1, 1, null); + }, /ERR_INVALID_ARG_TYPE/); + }); + + it("should throw if x or y is NaN", () => { + // Verify that cursorTo() throws if x or y is NaN. + assert.throws(() => { + readline.cursorTo(writable, NaN); + }, /ERR_INVALID_ARG_VALUE/); + + assert.throws(() => { + readline.cursorTo(writable, 1, NaN); + }, /ERR_INVALID_ARG_VALUE/); + + assert.throws(() => { + readline.cursorTo(writable, NaN, NaN); + }, /ERR_INVALID_ARG_VALUE/); + }); +}); + +describe("readline.emitKeyPressEvents()", () => { + // emitKeypressEvents is thoroughly tested in test-readline-keys.js. + // However, that test calls it implicitly. This is just a quick sanity check + // to verify that it works when called explicitly. + + const expectedSequence = ["f", "o", "o"]; + const expectedKeys = [ + { sequence: "f", name: "f", ctrl: false, meta: false, shift: false }, + { sequence: "o", name: "o", ctrl: false, meta: false, shift: false }, + { sequence: "o", name: "o", ctrl: false, meta: false, shift: false }, + ]; + + it("should emit the expected sequence when keypress listener added after called", () => { + const stream = new PassThrough(); + const sequence: any[] = []; + const keys: any[] = []; + + readline.emitKeypressEvents(stream); + stream.on("keypress", (s, k) => { + sequence.push(s); + keys.push(k); + }); + stream.write("foo"); + + assert.deepStrictEqual(sequence, expectedSequence); + assert.deepStrictEqual(keys, expectedKeys); + }); + + it("should emit the expected sequence when keypress listener added before called", () => { + const stream = new PassThrough(); + const sequence: any[] = []; + const keys: any[] = []; + + stream.on("keypress", (s, k) => { + sequence.push(s); + keys.push(k); + }); + readline.emitKeypressEvents(stream); + stream.write("foo"); + + assert.deepStrictEqual(sequence, expectedSequence); + assert.deepStrictEqual(keys, expectedKeys); + }); + + it("should allow keypress listeners to be removed and added again", () => { + const stream = new PassThrough(); + const sequence: any[] = []; + const keys: any[] = []; + const keypressListener = (s, k) => { + sequence.push(s); + keys.push(k); + }; + + stream.on("keypress", keypressListener); + readline.emitKeypressEvents(stream); + stream.removeListener("keypress", keypressListener); + stream.write("foo"); + + assert.deepStrictEqual(sequence, []); + assert.deepStrictEqual(keys, []); + + stream.on("keypress", keypressListener); + stream.write("foo"); + + assert.deepStrictEqual(sequence, expectedSequence); + assert.deepStrictEqual(keys, expectedKeys); + }); +}); + +describe("readline.Interface", () => { + it("should allow valid escapeCodeTimeout to be set", () => { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + escapeCodeTimeout: 50, + }); + assert.strictEqual(rli.escapeCodeTimeout, 50); + rli.close(); + }); + + it("should throw on invalid escapeCodeTimeout", () => { + [null, {}, NaN, "50"].forEach((invalidInput) => { + assert.throws( + () => { + const fi = new FakeInput(); + const rli = new readline.Interface({ + input: fi, + output: fi, + escapeCodeTimeout: invalidInput, + }); + rli.close(); + }, + { + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + }, + ); + }); + }); + + it("should create valid instances of readline.Interface", () => { + const input = new FakeInput(); + const rl = readline.Interface({ input }); + assert.ok(rl instanceof readline.Interface); + }); + + it("should call completer when input emits data", (done) => { + const { mustCall } = createCallCheckCtx(done); + const fi = new FakeInput(); + const rli = new readline.Interface( + fi, + fi, + mustCall((line) => [[], line]), + true, + ); + + assert.ok(rli instanceof readline.Interface); + fi.emit("data", "a\t"); + rli.close(); + }); + + it("should allow crlfDelay to be set", () => { + [undefined, 50, 0, 100.5, 5000].forEach((crlfDelay) => { + const [rli] = getInterface({ crlfDelay }); + assert.strictEqual(rli.crlfDelay, Math.max(crlfDelay || 100, 100)); + rli.close(); + }); + }); + + it("should throw if completer is not a function or is undefined", () => { + ["not an array", 123, 123n, {}, true, Symbol(), null].forEach((invalid) => { + assert.throws( + () => { + readline.createInterface({ + input, + completer: invalid, + }); + }, + { + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + }, + ); + }); + }); + + it("should throw if history is not an array", () => { + ["not an array", 123, 123, {}, true, Symbol(), null].forEach((history) => { + assert.throws( + () => { + readline.createInterface({ + input, + history, + }); + }, + { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + }, + ); + }); + }); + + it("should throw if historySize is not a positive number", () => { + ["not a number", -1, NaN, {}, true, Symbol(), null].forEach( + (historySize) => { + assert.throws( + () => { + readline.createInterface({ + input, + historySize, + }); + }, + { + // TODO: Revert to Range error when properly implemented errors with multiple bases + // name: "RangeError", + name: "TypeError", + code: "ERR_INVALID_ARG_VALUE", + }, + ); + }, + ); + }); + + it("should throw on invalid tabSize", () => { + // Check for invalid tab sizes. + assert.throws( + () => + new readline.Interface({ + input, + tabSize: 0, + }), + { code: "ERR_OUT_OF_RANGE" }, + ); + + assert.throws( + () => + new readline.Interface({ + input, + tabSize: "4", + }), + { code: "ERR_INVALID_ARG_TYPE" }, + ); + + assert.throws( + () => + new readline.Interface({ + input, + tabSize: 4.5, + }), + { + code: "ERR_OUT_OF_RANGE", + // message: + // 'The value of "tabSize" is out of range. ' + + // "It must be an integer. Received 4.5", + }, + ); + }); + + // Sending a single character with no newline + it("should not emit line when only a single character sent with no newline", (done) => { + const { mustNotCall } = createCallCheckCtx(done); + const fi = new FakeInput(); + const rli = new readline.Interface(fi, {}); + rli.on("line", mustNotCall()); + fi.emit("data", "a"); + rli.close(); + }); + + it("should treat \\r like \\n when alone", (done) => { + const { mustCall } = createCallCheckCtx(done); + // Sending multiple newlines at once that does not end with a new line and a + // `end` event(last line is). \r should behave like \n when alone. + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ["foo", "bar", "baz", "bat"]; + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length - 1), + ); + fi.emit("data", expectedLines.join("\r")); + rli.close(); + }); + + // \r at start of input should output blank line + it("should output blank line when \\r at start of input", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ["", "foo"]; + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, expectedLines.shift()); + }, expectedLines.length), + ); + fi.emit("data", "\rfoo\r"); + rli.close(); + }); + + // \t does not become part of the input when there is a completer function + it("should not include \\t in input when there is a completer function", (done) => { + const { mustCall } = createCallCheckCtx(done); + const completer = (line) => [[], line]; + const [rli, fi] = getInterface({ terminal: true, completer }); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "foo"); + }), + ); + for (const character of "\tfo\to\t") { + fi.emit("data", character); + } + fi.emit("data", "\n"); + rli.close(); + }); + + // \t when there is no completer function should behave like an ordinary + // character + it("should treat \\t as an ordinary character when there is no completer function", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true }); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "\t"); + }), + ); + fi.emit("data", "\t"); + fi.emit("data", "\n"); + rli.close(); + }); + + // Adding history lines should emit the history event with + // the history array + it("should emit history event when adding history lines", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true }); + const expectedLines = ["foo", "bar", "baz", "bat"]; + rli.on( + "history", + mustCall((history) => { + const expectedHistory = expectedLines + .slice(0, history.length) + .reverse(); + assert.deepStrictEqual(history, expectedHistory); + }, expectedLines.length), + ); + for (const line of expectedLines) { + fi.emit("data", `${line}\n`); + } + rli.close(); + }); + + // Altering the history array in the listener should not alter + // the line being processed + it("should not alter the line being processed when history is altered", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true }); + const expectedLine = "foo"; + rli.on( + "history", + mustCall((history) => { + assert.strictEqual(history[0], expectedLine); + history.shift(); + }), + ); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, expectedLine); + assert.strictEqual(rli.history.length, 0); + }), + ); + fi.emit("data", `${expectedLine}\n`); + rli.close(); + }); + + // Duplicate lines are removed from history when + // `options.removeHistoryDuplicates` is `true` + it("should remove duplicate lines from history when removeHistoryDuplicates is true", () => { + const [rli, fi] = getInterface({ + terminal: true, + removeHistoryDuplicates: true, + }); + const expectedLines = ["foo", "bar", "baz", "bar", "bat", "bat"]; + // ['foo', 'baz', 'bar', bat']; + let callCount = 0; + rli.on("line", (line) => { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit("data", `${expectedLines.join("\n")}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit("keypress", ".", { name: "up" }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'foo' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + fi.emit("keypress", ".", { name: "down" }); // 'baz' + assert.strictEqual(rli.line, "baz"); + assert.strictEqual(rli.historyIndex, 2); + fi.emit("keypress", ".", { name: "n", ctrl: true }); // 'bar' + assert.strictEqual(rli.line, "bar"); + assert.strictEqual(rli.historyIndex, 1); + fi.emit("keypress", ".", { name: "n", ctrl: true }); + assert.strictEqual(rli.line, "bat"); + assert.strictEqual(rli.historyIndex, 0); + // Activate the substring history search. + fi.emit("keypress", ".", { name: "down" }); // 'bat' + assert.strictEqual(rli.line, "bat"); + assert.strictEqual(rli.historyIndex, -1); + // Deactivate substring history search. + fi.emit("keypress", ".", { name: "backspace" }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, "ba"); + // Activate the substring history search. + fi.emit("keypress", ".", { name: "down" }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, "ba"); + fi.emit("keypress", ".", { name: "down" }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, "ba"); + fi.emit("keypress", ".", { name: "up" }); // 'bat' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, "bat"); + fi.emit("keypress", ".", { name: "up" }); // 'bar' + assert.strictEqual(rli.historyIndex, 1); + assert.strictEqual(rli.line, "bar"); + fi.emit("keypress", ".", { name: "up" }); // 'baz' + assert.strictEqual(rli.historyIndex, 2); + assert.strictEqual(rli.line, "baz"); + fi.emit("keypress", ".", { name: "up" }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, "ba"); + fi.emit("keypress", ".", { name: "up" }); // 'ba' + assert.strictEqual(rli.historyIndex, 4); + assert.strictEqual(rli.line, "ba"); + // Deactivate substring history search and reset history index. + fi.emit("keypress", ".", { name: "right" }); // 'ba' + assert.strictEqual(rli.historyIndex, -1); + assert.strictEqual(rli.line, "ba"); + // Substring history search activated. + fi.emit("keypress", ".", { name: "up" }); // 'ba' + assert.strictEqual(rli.historyIndex, 0); + assert.strictEqual(rli.line, "bat"); + rli.close(); + }); + + // Duplicate lines are not removed from history when + // `options.removeHistoryDuplicates` is `false` + it("should not remove duplicate lines from history when removeHistoryDuplicates is false", () => { + const [rli, fi] = getInterface({ + terminal: true, + removeHistoryDuplicates: false, + }); + const expectedLines = ["foo", "bar", "baz", "bar", "bat", "bat"]; + let callCount = 0; + rli.on("line", (line) => { + assert.strictEqual(line, expectedLines[callCount]); + callCount++; + }); + fi.emit("data", `${expectedLines.join("\n")}\n`); + assert.strictEqual(callCount, expectedLines.length); + fi.emit("keypress", ".", { name: "up" }); // 'bat' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'bar' + assert.notStrictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'baz' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'bar' + assert.strictEqual(rli.line, expectedLines[--callCount]); + fi.emit("keypress", ".", { name: "up" }); // 'foo' + assert.strictEqual(rli.line, expectedLines[--callCount]); + assert.strictEqual(callCount, 0); + rli.close(); + }); + + // Regression test for repl freeze, #1968: + // check that nothing fails if 'keypress' event throws. + it("should not fail if keypress throws", () => { + const [rli, fi] = getInterface({ terminal: true }); + const keys = [] as string[]; + const err = new Error("bad thing happened"); + fi.on("keypress", (key: string) => { + keys.push(key); + if (key === "X") { + throw err; + } + }); + assert.throws( + () => fi.emit("data", "fooX"), + (e) => { + console.log("ERRROR!", e); + assert.strictEqual(e, err); + return true; + }, + ); + fi.emit("data", "bar"); + assert.strictEqual(keys.join(""), "fooXbar"); + rli.close(); + }); + + // History is bound + it("should bind history", () => { + const [rli, fi] = getInterface({ terminal: true, historySize: 2 }); + const lines = ["line 1", "line 2", "line 3"]; + fi.emit("data", lines.join("\n") + "\n"); + assert.strictEqual(rli.history.length, 2); + assert.strictEqual(rli.history[0], "line 3"); + assert.strictEqual(rli.history[1], "line 2"); + }); + + // Question + it("should handle question", () => { + const [rli] = getInterface({ terminal: true }); + const expectedLines = ["foo"]; + rli.question(expectedLines[0], () => rli.close()); + assertCursorRowsAndCols(rli, 0, expectedLines[0].length); + rli.close(); + }); + + // Sending a multi-line question + it("should handle multi-line questions", () => { + const [rli] = getInterface({ terminal: true }); + const expectedLines = ["foo", "bar"]; + rli.question(expectedLines.join("\n"), () => rli.close()); + assertCursorRowsAndCols( + rli, + expectedLines.length - 1, + expectedLines.slice(-1)[0].length, + ); + rli.close(); + }); + + it("should handle beginning and end of line", () => { + // Beginning and end of line + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + assertCursorRowsAndCols(rli, 0, 0); + fi.emit("keypress", ".", { ctrl: true, name: "e" }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); + }); + + it("should handle back and forward one character", () => { + // Back and Forward one character + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Back one character + fi.emit("keypress", ".", { ctrl: true, name: "b" }); + assertCursorRowsAndCols(rli, 0, 18); + // Back one character + fi.emit("keypress", ".", { ctrl: true, name: "b" }); + assertCursorRowsAndCols(rli, 0, 17); + // Forward one character + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + assertCursorRowsAndCols(rli, 0, 18); + // Forward one character + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); + }); + + // Back and Forward one astral character + it("should handle going back and forward one astral character", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "đť"); + + // Move left one character/code point + fi.emit("keypress", ".", { name: "left" }); + assertCursorRowsAndCols(rli, 0, 0); + + // Move right one character/code point + fi.emit("keypress", ".", { name: "right" }); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "đť"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // Two astral characters left + it("should handle two astral characters left", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "đť"); + + // Move left one character/code point + fi.emit("keypress", ".", { name: "left" }); + assertCursorRowsAndCols(rli, 0, 0); + + fi.emit("data", "đ"); + assertCursorRowsAndCols(rli, 0, 2); + + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "đđť"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // Two astral characters right + it("should handle two astral characters right", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "đť"); + + // Move left one character/code point + fi.emit("keypress", ".", { name: "right" }); + assertCursorRowsAndCols(rli, 0, 2); + + fi.emit("data", "đ"); + assertCursorRowsAndCols(rli, 0, 4); + + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "đťđ"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + it("should handle wordLeft and wordRight", () => { + // `wordLeft` and `wordRight` + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + fi.emit("keypress", ".", { ctrl: true, name: "left" }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit("keypress", ".", { meta: true, name: "b" }); + assertCursorRowsAndCols(rli, 0, 10); + fi.emit("keypress", ".", { ctrl: true, name: "right" }); + assertCursorRowsAndCols(rli, 0, 16); + fi.emit("keypress", ".", { meta: true, name: "f" }); + assertCursorRowsAndCols(rli, 0, 19); + rli.close(); + }); + + // `deleteWordLeft` + it("should handle deleteWordLeft", (done) => { + const { mustCall } = createCallCheckCtx(done); + [ + { ctrl: true, name: "w" }, + { ctrl: true, name: "backspace" }, + { meta: true, name: "backspace" }, + ].forEach((deleteWordLeftKey) => { + let [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + fi.emit("keypress", ".", { ctrl: true, name: "left" }); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick fox"); + }), + ); + fi.emit("keypress", ".", deleteWordLeftKey); + fi.emit("data", "\n"); + rli.close(); + + // No effect if pressed at beginning of line + [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick brown fox"); + }), + ); + fi.emit("keypress", ".", deleteWordLeftKey); + fi.emit("data", "\n"); + rli.close(); + }); + }); + + // `deleteWordRight` + it("should handle deleteWordRight", (done) => { + const { mustCall } = createCallCheckCtx(done); + [ + { ctrl: true, name: "delete" }, + { meta: true, name: "delete" }, + { meta: true, name: "d" }, + ].forEach((deleteWordRightKey) => { + let [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + fi.emit("keypress", ".", { ctrl: true, name: "left" }); + fi.emit("keypress", ".", { ctrl: true, name: "left" }); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick fox"); + }), + ); + fi.emit("keypress", ".", deleteWordRightKey); + fi.emit("data", "\n"); + rli.close(); + + // No effect if pressed at end of line + [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick brown fox"); + }), + ); + fi.emit("keypress", ".", deleteWordRightKey); + fi.emit("data", "\n"); + rli.close(); + }); + }); + + // deleteLeft + it("should handle deleteLeft", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete left character + fi.emit("keypress", ".", { ctrl: true, name: "h" }); + assertCursorRowsAndCols(rli, 0, 18); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick brown fo"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // deleteLeft astral character + it("should handle deleteLeft astral character", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "đť"); + assertCursorRowsAndCols(rli, 0, 2); + // Delete left character + fi.emit("keypress", ".", { ctrl: true, name: "h" }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, ""); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // deleteRight + it("should handle deleteRight", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit("keypress", ".", { ctrl: true, name: "d" }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "he quick brown fox"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // deleteRight astral character + it("should handle deleteRight of astral characters", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "đť"); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete right character + fi.emit("keypress", ".", { ctrl: true, name: "d" }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, ""); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // deleteLineLeft + it("should handle deleteLineLeft", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete from current to start of line + fi.emit("keypress", ".", { ctrl: true, shift: true, name: "backspace" }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, ""); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // deleteLineRight + it("should handle deleteLineRight", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + assertCursorRowsAndCols(rli, 0, 0); + + // Delete from current to end of line + fi.emit("keypress", ".", { ctrl: true, shift: true, name: "delete" }); + assertCursorRowsAndCols(rli, 0, 0); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, ""); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + // yank + it("should handle yank", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + // Move forward one char + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + // Delete the right part + fi.emit("keypress", ".", { ctrl: true, shift: true, name: "delete" }); + assertCursorRowsAndCols(rli, 0, 1); + + // Yank + fi.emit("keypress", ".", { ctrl: true, name: "y" }); + assertCursorRowsAndCols(rli, 0, 19); + + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "the quick brown fox"); + }), + ); + + fi.emit("data", "\n"); + rli.close(); + }); + + // yank pop + it("should handle yank pop", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + // Move forward one char + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + // Delete the right part + fi.emit("keypress", ".", { ctrl: true, shift: true, name: "delete" }); + assertCursorRowsAndCols(rli, 0, 1); + // Yank + fi.emit("keypress", ".", { ctrl: true, name: "y" }); + assertCursorRowsAndCols(rli, 0, 19); + + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + // Move forward four chars + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + fi.emit("keypress", ".", { ctrl: true, name: "f" }); + // Delete the right part + fi.emit("keypress", ".", { ctrl: true, shift: true, name: "delete" }); + assertCursorRowsAndCols(rli, 0, 4); + // Go to the start of the line + fi.emit("keypress", ".", { ctrl: true, name: "a" }); + assertCursorRowsAndCols(rli, 0, 0); + + // Yank: 'quick brown fox|the ' + fi.emit("keypress", ".", { ctrl: true, name: "y" }); + // Yank pop: 'he quick brown fox|the' + fi.emit("keypress", ".", { meta: true, name: "y" }); + assertCursorRowsAndCols(rli, 0, 18); + + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "he quick brown foxthe "); + }), + ); + + fi.emit("data", "\n"); + rli.close(); + }); + + // Close readline interface + it("Should close readline interface", () => { + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("keypress", ".", { ctrl: true, name: "c" }); + assert.ok(rli.closed); + }); + + // Multi-line input cursor position + it("should handle multi-line input cursors", () => { + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.columns = 10; + fi.emit("data", "multi-line text"); + assertCursorRowsAndCols(rli, 1, 5); + rli.close(); + }); + + // Multi-line input cursor position and long tabs + it("should handle long tabs", () => { + const [rli, fi] = getInterface({ + tabSize: 16, + terminal: true, + prompt: "", + }); + fi.columns = 10; + fi.emit("data", "multi-line\ttext \t"); + assert.strictEqual(rli.cursor, 17); + assertCursorRowsAndCols(rli, 3, 2); + rli.close(); + }); + + // Check for the default tab size. + it("should use the default tab size", () => { + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick\tbrown\tfox"); + assert.strictEqual(rli.cursor, 19); + // The first tab is 7 spaces long, the second one 3 spaces. + assertCursorRowsAndCols(rli, 0, 27); + }); + + // Multi-line prompt cursor position + it("should handle multi-line prompt cursor position", () => { + const [rli, fi] = getInterface({ + terminal: true, + prompt: "\nfilledline\nwraping text\n> ", + }); + fi.columns = 10; + fi.emit("data", "t"); + assertCursorRowsAndCols(rli, 4, 3); + rli.close(); + }); + + // Undo & Redo + it("should undo and redo", () => { + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + fi.emit("data", "the quick brown fox"); + assertCursorRowsAndCols(rli, 0, 19); + + // Delete the last eight chars + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ",", { ctrl: true, shift: false, name: "k" }); + + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ".", { ctrl: true, shift: false, name: "b" }); + fi.emit("keypress", ",", { ctrl: true, shift: false, name: "k" }); + + assertCursorRowsAndCols(rli, 0, 11); + // Perform undo twice + fi.emit("keypress", ",", { sequence: "\x1F" }); + assert.strictEqual(rli.line, "the quick brown"); + fi.emit("keypress", ",", { sequence: "\x1F" }); + assert.strictEqual(rli.line, "the quick brown fox"); + // Perform redo twice + fi.emit("keypress", ",", { sequence: "\x1E" }); + assert.strictEqual(rli.line, "the quick brown"); + fi.emit("keypress", ",", { sequence: "\x1E" }); + assert.strictEqual(rli.line, "the quick b"); + fi.emit("data", "\n"); + rli.close(); + }); + + // Clear the whole screen + it("should clear the whole screen", (done) => { + const { mustCall } = createCallCheckCtx(done); + const [rli, fi] = getInterface({ terminal: true, prompt: "" }); + const lines = ["line 1", "line 2", "line 3"]; + fi.emit("data", lines.join("\n")); + fi.emit("keypress", ".", { ctrl: true, name: "l" }); + assertCursorRowsAndCols(rli, 0, 6); + rli.on( + "line", + mustCall((line) => { + assert.strictEqual(line, "line 3"); + }), + ); + fi.emit("data", "\n"); + rli.close(); + }); + + it("should treat wide characters as two columns", () => { + assert.strictEqual(getStringWidth("a"), 1); + assert.strictEqual(getStringWidth("ă"), 2); + assert.strictEqual(getStringWidth("č°˘"), 2); + assert.strictEqual(getStringWidth("ęł "), 2); + assert.strictEqual(getStringWidth(String.fromCodePoint(0x1f251)), 2); + assert.strictEqual(getStringWidth("abcde"), 5); + assert.strictEqual(getStringWidth("ĺ¤ćą ă"), 6); + assert.strictEqual(getStringWidth("ăăźă.js"), 9); + assert.strictEqual(getStringWidth("ä˝ ĺĽ˝"), 4); + assert.strictEqual(getStringWidth("ěë
íě¸ě"), 10); + assert.strictEqual(getStringWidth("A\ud83c\ude00BC"), 5); + assert.strictEqual(getStringWidth("đ¨âđŠâđŚâđŚ"), 8); + assert.strictEqual(getStringWidth("đđˇăđťđ"), 9); + // TODO(BridgeAR): This should have a width of 4. + assert.strictEqual(getStringWidth("âŹâŞ"), 2); + assert.strictEqual(getStringWidth("\u0301\u200D\u200E"), 0); + }); + + // // Check if vt control chars are stripped + // assert.strictEqual(stripVTControlCharacters('\u001b[31m> \u001b[39m'), '> '); + // assert.strictEqual( + // stripVTControlCharacters('\u001b[31m> \u001b[39m> '), + // '> > ' + // ); + // assert.strictEqual(stripVTControlCharacters('\u001b[31m\u001b[39m'), ''); + // assert.strictEqual(stripVTControlCharacters('> '), '> '); + // assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m'), 2); + // assert.strictEqual(getStringWidth('\u001b[31m> \u001b[39m> '), 4); + // assert.strictEqual(getStringWidth('\u001b[31m\u001b[39m'), 0); + // assert.strictEqual(getStringWidth('> '), 2); + + // // Check EventEmitter memory leak + // for (let i = 0; i < 12; i++) { + // const rl = readline.createInterface({ + // input: process.stdin, + // output: process.stdout + // }); + // rl.close(); + // assert.strictEqual(isWarned(process.stdin._events), false); + // assert.strictEqual(isWarned(process.stdout._events), false); + // } + + // [true, false].forEach((terminal) => { + // // Disable history + // { + // const [rli, fi] = getInterface({ terminal, historySize: 0 }); + // assert.strictEqual(rli.historySize, 0); + + // fi.emit('data', 'asdf\n'); + // assert.deepStrictEqual(rli.history, []); + // rli.close(); + // } + + // // Default history size 30 + // { + // const [rli, fi] = getInterface({ terminal }); + // assert.strictEqual(rli.historySize, 30); + + // fi.emit('data', 'asdf\n'); + // assert.deepStrictEqual(rli.history, terminal ? ['asdf'] : []); + // rli.close(); + // } + + // // Sending a full line + // { + // const [rli, fi] = getInterface({ terminal }); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, 'asdf'); + // })); + // fi.emit('data', 'asdf\n'); + // } + + // // Sending a blank line + // { + // const [rli, fi] = getInterface({ terminal }); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, ''); + // })); + // fi.emit('data', '\n'); + // } + + // // Sending a single character with no newline and then a newline + // { + // const [rli, fi] = getInterface({ terminal }); + // let called = false; + // rli.on('line', (line) => { + // called = true; + // assert.strictEqual(line, 'a'); + // }); + // fi.emit('data', 'a'); + // assert.ok(!called); + // fi.emit('data', '\n'); + // assert.ok(called); + // rli.close(); + // } + + // // Sending multiple newlines at once + // { + // const [rli, fi] = getInterface({ terminal }); + // const expectedLines = ['foo', 'bar', 'baz']; + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, expectedLines.shift()); + // }, expectedLines.length)); + // fi.emit('data', `${expectedLines.join('\n')}\n`); + // rli.close(); + // } + + // // Sending multiple newlines at once that does not end with a new line + // { + // const [rli, fi] = getInterface({ terminal }); + // const expectedLines = ['foo', 'bar', 'baz', 'bat']; + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, expectedLines.shift()); + // }, expectedLines.length - 1)); + // fi.emit('data', expectedLines.join('\n')); + // rli.close(); + // } + + // // Sending multiple newlines at once that does not end with a new(empty) + // // line and a `end` event + // { + // const [rli, fi] = getInterface({ terminal }); + // const expectedLines = ['foo', 'bar', 'baz', '']; + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, expectedLines.shift()); + // }, expectedLines.length - 1)); + // rli.on('close', mustCall()); + // fi.emit('data', expectedLines.join('\n')); + // fi.emit('end'); + // rli.close(); + // } + + // // Sending a multi-byte utf8 char over multiple writes + // { + // const buf = Buffer.from('âŽ', 'utf8'); + // const [rli, fi] = getInterface({ terminal }); + // let callCount = 0; + // rli.on('line', (line) => { + // callCount++; + // assert.strictEqual(line, buf.toString('utf8')); + // }); + // for (const i of buf) { + // fi.emit('data', Buffer.from([i])); + // } + // assert.strictEqual(callCount, 0); + // fi.emit('data', '\n'); + // assert.strictEqual(callCount, 1); + // rli.close(); + // } + + // // Calling readline without `new` + // { + // const [rli, fi] = getInterface({ terminal }); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, 'asdf'); + // })); + // fi.emit('data', 'asdf\n'); + // rli.close(); + // } + + // // Calling the question callback + // { + // const [rli] = getInterface({ terminal }); + // rli.question('foo?', mustCall((answer) => { + // assert.strictEqual(answer, 'bar'); + // })); + // rli.write('bar\n'); + // rli.close(); + // } + + // // Calling the question callback with abort signal + // { + // const [rli] = getInterface({ terminal }); + // const { signal } = new AbortController(); + // rli.question('foo?', { signal }, mustCall((answer) => { + // assert.strictEqual(answer, 'bar'); + // })); + // rli.write('bar\n'); + // rli.close(); + // } + + // // Calling the question multiple times + // { + // const [rli] = getInterface({ terminal }); + // rli.question('foo?', mustCall((answer) => { + // assert.strictEqual(answer, 'baz'); + // })); + // rli.question('bar?', mustNotCall(() => { + // })); + // rli.write('baz\n'); + // rli.close(); + // } + + // // Calling the promisified question + // { + // const [rli] = getInterface({ terminal }); + // const question = util.promisify(rli.question).bind(rli); + // question('foo?') + // .then(mustCall((answer) => { + // assert.strictEqual(answer, 'bar'); + // })); + // rli.write('bar\n'); + // rli.close(); + // } + + // // Calling the promisified question with abort signal + // { + // const [rli] = getInterface({ terminal }); + // const question = util.promisify(rli.question).bind(rli); + // const { signal } = new AbortController(); + // question('foo?', { signal }) + // .then(mustCall((answer) => { + // assert.strictEqual(answer, 'bar'); + // })); + // rli.write('bar\n'); + // rli.close(); + // } + + // // Aborting a question + // { + // const ac = new AbortController(); + // const signal = ac.signal; + // const [rli] = getInterface({ terminal }); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, 'bar'); + // })); + // rli.question('hello?', { signal }, mustNotCall()); + // ac.abort(); + // rli.write('bar\n'); + // rli.close(); + // } + + // // Aborting a promisified question + // { + // const ac = new AbortController(); + // const signal = ac.signal; + // const [rli] = getInterface({ terminal }); + // const question = util.promisify(rli.question).bind(rli); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, 'bar'); + // })); + // question('hello?', { signal }) + // .then(mustNotCall()) + // .catch(mustCall((error) => { + // assert.strictEqual(error.name, 'AbortError'); + // })); + // ac.abort(); + // rli.write('bar\n'); + // rli.close(); + // } + + // // pre-aborted signal + // { + // const signal = AbortSignal.abort(); + // const [rli] = getInterface({ terminal }); + // rli.pause(); + // rli.on('resume', mustNotCall()); + // rli.question('hello?', { signal }, mustNotCall()); + // rli.close(); + // } + + // // pre-aborted signal promisified question + // { + // const signal = AbortSignal.abort(); + // const [rli] = getInterface({ terminal }); + // const question = util.promisify(rli.question).bind(rli); + // rli.on('resume', mustNotCall()); + // rli.pause(); + // question('hello?', { signal }) + // .then(mustNotCall()) + // .catch(mustCall((error) => { + // assert.strictEqual(error.name, 'AbortError'); + // })); + // rli.close(); + // } + + // // Call question after close + // { + // const [rli, fi] = getInterface({ terminal }); + // rli.question('What\'s your name?', mustCall((name) => { + // assert.strictEqual(name, 'Node.js'); + // rli.close(); + // assert.throws(() => { + // rli.question('How are you?', mustNotCall()); + // }, { + // name: 'Error', + // code: 'ERR_USE_AFTER_CLOSE' + // }); + // assert.notStrictEqual(rli.getPrompt(), 'How are you?'); + // })); + // fi.emit('data', 'Node.js\n'); + // } + + // // Call promisified question after close + // { + // const [rli, fi] = getInterface({ terminal }); + // const question = util.promisify(rli.question).bind(rli); + // question('What\'s your name?').then(mustCall((name) => { + // assert.strictEqual(name, 'Node.js'); + // rli.close(); + // question('How are you?') + // .then(mustNotCall(), expectsError({ + // code: 'ERR_USE_AFTER_CLOSE', + // name: 'Error' + // })); + // assert.notStrictEqual(rli.getPrompt(), 'How are you?'); + // })); + // fi.emit('data', 'Node.js\n'); + // } + + // // Can create a new readline Interface with a null output argument + // { + // const [rli, fi] = getInterface({ output: null, terminal }); + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, 'asdf'); + // })); + // fi.emit('data', 'asdf\n'); + + // rli.setPrompt('ddd> '); + // rli.prompt(); + // rli.write("really shouldn't be seeing this"); + // rli.question('What do you think of node.js? ', (answer) => { + // console.log('Thank you for your valuable feedback:', answer); + // rli.close(); + // }); + // } + + // // Calling the getPrompt method + // { + // const expectedPrompts = ['$ ', '> ']; + // const [rli] = getInterface({ terminal }); + // for (const prompt of expectedPrompts) { + // rli.setPrompt(prompt); + // assert.strictEqual(rli.getPrompt(), prompt); + // } + // } + + // { + // const expected = terminal ? + // ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] : + // ['$ ']; + + // const output = new Writable({ + // write: mustCall((chunk, enc, cb) => { + // assert.strictEqual(chunk.toString(), expected.shift()); + // cb(); + // rl.close(); + // }, expected.length) + // }); + + // const rl = readline.createInterface({ + // input: new Readable({ read: mustCall() }), + // output, + // prompt: '$ ', + // terminal + // }); + + // rl.prompt(); + + // assert.strictEqual(rl.getPrompt(), '$ '); + // } + + // { + // const fi = new FakeInput(); + // assert.deepStrictEqual(fi.listeners(terminal ? 'keypress' : 'data'), []); + // } + + // // Emit two line events when the delay + // // between \r and \n exceeds crlfDelay + // { + // const crlfDelay = 200; + // const [rli, fi] = getInterface({ terminal, crlfDelay }); + // let callCount = 0; + // rli.on('line', () => { + // callCount++; + // }); + // fi.emit('data', '\r'); + // setTimeout(mustCall(() => { + // fi.emit('data', '\n'); + // assert.strictEqual(callCount, 2); + // rli.close(); + // }), crlfDelay + 10); + // } + + // // For the purposes of the following tests, we do not care about the exact + // // value of crlfDelay, only that the behaviour conforms to what's expected. + // // Setting it to Infinity allows the test to succeed even under extreme + // // CPU stress. + // const crlfDelay = Infinity; + + // // Set crlfDelay to `Infinity` is allowed + // { + // const delay = 200; + // const [rli, fi] = getInterface({ terminal, crlfDelay }); + // let callCount = 0; + // rli.on('line', () => { + // callCount++; + // }); + // fi.emit('data', '\r'); + // setTimeout(mustCall(() => { + // fi.emit('data', '\n'); + // assert.strictEqual(callCount, 1); + // rli.close(); + // }), delay); + // } + + // // Sending multiple newlines at once that does not end with a new line + // // and a `end` event(last line is) + + // // \r\n should emit one line event, not two + // { + // const [rli, fi] = getInterface({ terminal, crlfDelay }); + // const expectedLines = ['foo', 'bar', 'baz', 'bat']; + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, expectedLines.shift()); + // }, expectedLines.length - 1)); + // fi.emit('data', expectedLines.join('\r\n')); + // rli.close(); + // } + + // // \r\n should emit one line event when split across multiple writes. + // { + // const [rli, fi] = getInterface({ terminal, crlfDelay }); + // const expectedLines = ['foo', 'bar', 'baz', 'bat']; + // let callCount = 0; + // rli.on('line', mustCall((line) => { + // assert.strictEqual(line, expectedLines[callCount]); + // callCount++; + // }, expectedLines.length)); + // expectedLines.forEach((line) => { + // fi.emit('data', `${line}\r`); + // fi.emit('data', '\n'); + // }); + // rli.close(); + // } + + // // Emit one line event when the delay between \r and \n is + // // over the default crlfDelay but within the setting value. + // { + // const delay = 125; + // const [rli, fi] = getInterface({ terminal, crlfDelay }); + // let callCount = 0; + // rli.on('line', () => callCount++); + // fi.emit('data', '\r'); + // setTimeout(mustCall(() => { + // fi.emit('data', '\n'); + // assert.strictEqual(callCount, 1); + // rli.close(); + // }), delay); + // } + // }); + + // // Ensure that the _wordLeft method works even for large input + // { + // const input = new Readable({ + // read() { + // this.push('\x1B[1;5D'); // CTRL + Left + // this.push(null); + // }, + // }); + // const output = new Writable({ + // write: mustCall((data, encoding, cb) => { + // assert.strictEqual(rl.cursor, rl.line.length - 1); + // cb(); + // }), + // }); + // const rl = new readline.createInterface({ + // input, + // output, + // terminal: true, + // }); + // rl.line = `a${' '.repeat(1e6)}a`; + // rl.cursor = rl.line.length; + // } + + // { + // const fi = new FakeInput(); + // const signal = AbortSignal.abort(); + + // const rl = readline.createInterface({ + // input: fi, + // output: fi, + // signal, + // }); + // rl.on('close', mustCall()); + // assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + // } + + // { + // const fi = new FakeInput(); + // const ac = new AbortController(); + // const { signal } = ac; + // const rl = readline.createInterface({ + // input: fi, + // output: fi, + // signal, + // }); + // assert.strictEqual(getEventListeners(signal, 'abort').length, 1); + // rl.on('close', mustCall()); + // ac.abort(); + // assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + // } + + // { + // const fi = new FakeInput(); + // const ac = new AbortController(); + // const { signal } = ac; + // const rl = readline.createInterface({ + // input: fi, + // output: fi, + // signal, + // }); + // assert.strictEqual(getEventListeners(signal, "abort").length, 1); + // rl.close(); + // assert.strictEqual(getEventListeners(signal, "abort").length, 0); + // } + + // { + // // Constructor throws if signal is not an abort signal + // assert.throws(() => { + // readline.createInterface({ + // input: new FakeInput(), + // signal: {}, + // }); + // }, { + // name: 'TypeError', + // code: 'ERR_INVALID_ARG_TYPE' + // }); + // } +}); + +describe("readline.createInterface()", () => { + it("should emit line when input ends line", (done) => { + const createDone = createDoneDotAll(done); + const lineDone = createDone(2000); + const { mustCall } = createCallCheckCtx(createDone(2000)); + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + rl.on( + "line", + mustCall((data) => { + assert.strictEqual(data, "abc"); + lineDone(); + }), + ); + + input.end("abc"); + }); + + it("should not emit line when input ends without newline", (done) => { + const { mustNotCall } = createCallCheckCtx(done); + + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + rl.on("line", mustNotCall("must not be called before newline")); + input.write("abc"); + }); + + it("should read line by line", (done) => { + const createDone = createDoneDotAll(done); + const { mustCall } = createCallCheckCtx(createDone(3000)); + const lineDone = createDone(2000); + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + rl.on( + "line", + mustCall((data) => { + assert.strictEqual(data, "abc"); + lineDone(); + }), + ); + + input.write("abc\n"); + }); + + it("should respond to home and end sequences for common pttys ", () => { + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + rl.write("foo"); + assert.strictEqual(rl.cursor, 3); + + const key = { + xterm: { + home: ["\x1b[H", { ctrl: true, name: "a" }], + end: ["\x1b[F", { ctrl: true, name: "e" }], + }, + gnome: { + home: ["\x1bOH", { ctrl: true, name: "a" }], + end: ["\x1bOF", { ctrl: true, name: "e" }], + }, + rxvt: { + home: ["\x1b[7", { ctrl: true, name: "a" }], + end: ["\x1b[8", { ctrl: true, name: "e" }], + }, + putty: { + home: ["\x1b[1~", { ctrl: true, name: "a" }], + end: ["\x1b[>~", { ctrl: true, name: "e" }], + }, + }; + + [key.xterm, key.gnome, key.rxvt, key.putty].forEach((key) => { + rl.write.apply(rl, key.home); + assert.strictEqual(rl.cursor, 0); + rl.write.apply(rl, key.end); + assert.strictEqual(rl.cursor, 3); + }); + }); + + it("should allow for cursor movement with meta-f and meta-b", () => { + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + const key = { + xterm: { + home: ["\x1b[H", { ctrl: true, name: "a" }], + metab: ["\x1bb", { meta: true, name: "b" }], + metaf: ["\x1bf", { meta: true, name: "f" }], + }, + }; + + rl.write("foo bar.hop/zoo"); + rl.write.apply(rl, key.xterm.home); + [ + { cursor: 4, key: key.xterm.metaf }, + { cursor: 7, key: key.xterm.metaf }, + { cursor: 8, key: key.xterm.metaf }, + { cursor: 11, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metaf }, + { cursor: 15, key: key.xterm.metaf }, + { cursor: 12, key: key.xterm.metab }, + { cursor: 11, key: key.xterm.metab }, + { cursor: 8, key: key.xterm.metab }, + { cursor: 7, key: key.xterm.metab }, + { cursor: 4, key: key.xterm.metab }, + { cursor: 0, key: key.xterm.metab }, + ].forEach(function (action) { + rl.write.apply(rl, action.key); + assert.strictEqual(rl.cursor, action.cursor); + }); + }); + + it("should properly allow for cursor movement with meta-d", () => { + const input = new PassThrough(); + const rl = readline.createInterface({ + terminal: true, + input: input, + }); + + const key = { + xterm: { + home: ["\x1b[H", { ctrl: true, name: "a" }], + metad: ["\x1bd", { meta: true, name: "d" }], + }, + }; + + rl.write("foo bar.hop/zoo"); + rl.write.apply(rl, key.xterm.home); + ["bar.hop/zoo", ".hop/zoo", "hop/zoo", "/zoo", "zoo", ""].forEach(function ( + expectedLine, + ) { + rl.write.apply(rl, key.xterm.metad); + assert.strictEqual(rl.cursor, 0); + assert.strictEqual(rl.line, expectedLine); + }); + }); + + // TODO: Actual pseudo-tty test + // it("should operate correctly when process.env.DUMB is set", () => { + // process.env.TERM = "dumb"; + // const rl = readline.createInterface({ + // input: process.stdin, + // output: process.stdout, + // }); + // rl.write("text"); + // rl.write(null, { ctrl: true, name: "u" }); + // rl.write(null, { name: "return" }); + // rl.write("text"); + // rl.write(null, { name: "backspace" }); + // rl.write(null, { name: "escape" }); + // rl.write(null, { name: "enter" }); + // rl.write("text"); + // rl.write(null, { ctrl: true, name: "c" }); + // }); +}); diff --git a/test/bun.js/readline_promises.node.test.ts b/test/bun.js/readline_promises.node.test.ts new file mode 100644 index 000000000..eadd289e3 --- /dev/null +++ b/test/bun.js/readline_promises.node.test.ts @@ -0,0 +1,55 @@ +import { describe, it } from "bun:test"; +import readlinePromises from "node:readline/promises"; +import { EventEmitter } from "node:events"; +import { + createDoneDotAll, + createCallCheckCtx, + assert, +} from "./node-test-helpers"; + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +class FakeInput extends EventEmitter { + output = ""; + resume() {} + pause() {} + write(data) { + this.output += data; + } + end() {} + reset() { + this.output = ""; + } +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +describe("readline/promises.createInterface()", () => { + it("should throw an error when failed completion", (done) => { + const createDone = createDoneDotAll(done); + const { mustCall, mustNotCall } = createCallCheckCtx(createDone()); + + const fi = new FakeInput(); + const rli = new readlinePromises.Interface({ + input: fi, + output: fi, + terminal: true, + completer: mustCall(() => Promise.reject(new Error("message"))), + }); + + rli.on("line", mustNotCall()); + fi.emit("data", "\t"); + const outCheckDone = createDone(); + process.nextTick(() => { + console.log("output", fi.output); + assert.match(fi.output, /^Tab completion error/); + fi.reset(); + outCheckDone(); + }); + rli.close(); + }); +}); |