diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js index 653e14cbb0c071..0fd28a20b033d4 100644 --- a/lib/internal/repl/utils.js +++ b/lib/internal/repl/utils.js @@ -3,21 +3,31 @@ const { ArrayPrototypeFilter, ArrayPrototypeIncludes, + ArrayPrototypeJoin, ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypeSort, Boolean, FunctionPrototypeBind, + FunctionPrototypeToString, MathMin, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectGetOwnPropertyNames, + ObjectGetPrototypeOf, RegExpPrototypeExec, SafeSet, SafeStringIterator, StringPrototypeIndexOf, StringPrototypeLastIndexOf, + StringPrototypePadEnd, StringPrototypeReplaceAll, StringPrototypeSlice, StringPrototypeStartsWith, StringPrototypeToLowerCase, StringPrototypeTrim, Symbol, + SymbolFor, } = primordials; const { sendInspectorCommand } = require('internal/util/inspector'); @@ -841,6 +851,224 @@ function setReplBuiltinLibs(value) { _builtinLibs = value; } +/** + * Creates a `help()` function for use in the REPL context. + * - `help()` -- prints REPL usage guide (commands, shortcuts) + * - `help(value)` -- introspects a value, showing type, properties, methods, + * prototype chain, and function signature info. + * + * Uses `util.styleText` to dogfood core coloring. Output goes to + * `repl.output` so it respects the terminal's color capabilities. + * @param {object} repl The REPLServer instance. + * @returns {Function} The `help` function to be injected into the context. + */ +function createREPLHelp(repl) { + const { styleText } = require('util'); + const kStyleOpts = { __proto__: null, validateStream: false }; + + function style(color, text) { + if (!repl.useColors) return `${text}`; + return styleText(color, `${text}`, kStyleOpts); + } + + function help(value) { + const lines = []; + + if (arguments.length === 0) { + // No argument: show REPL guide. + ArrayPrototypePush(lines, + style('bold', 'Node.js REPL Help'), + '', + style('yellow', 'Commands:'), + ); + + const cmds = repl.commands; + const names = ArrayPrototypeSort(ObjectGetOwnPropertyNames(cmds)); + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const cmd = cmds[name]; + const desc = cmd.help || ''; + ArrayPrototypePush(lines, + ` ${style('green', `.${StringPrototypePadEnd(name, 12)}`)}${desc}`); + } + + ArrayPrototypePush(lines, + '', + style('yellow', 'Shortcuts:'), + ` ${style('green', 'Ctrl+C ')}Abort current expression`, + ` ${style('green', 'Ctrl+D ')}Exit the REPL`, + ` ${style('green', 'Ctrl+L ')}Clear the screen`, + ` ${style('green', 'Tab ')}Autocomplete`, + ` ${style('green', 'Up / Down ')}Navigate history`, + ` ${style('green', '_ ')}Last evaluated result`, + ` ${style('green', '_error ')}Last thrown error`, + '', + style('yellow', 'Usage:'), + ` ${style('cyan', 'help(value)')} Inspect any value`, + ` ${style('cyan', 'help(fn)')} Show function signature`, + ` ${style('cyan', 'help(obj)')} List object properties`, + ); + + repl.output.write(ArrayPrototypeJoin(lines, '\n') + '\n'); + return undefined; + } + + // --- Introspect a value --- + + const type = value === null ? 'null' : typeof value; + + // Header: type and constructor info. + if (type === 'function') { + const fnStr = FunctionPrototypeToString(value); + const isAsync = RegExpPrototypeExec(/^async\s/, fnStr) !== null; + const isGenerator = RegExpPrototypeExec(/^(async\s+)?function\s*\*/, fnStr) !== null; + const isClass = RegExpPrototypeExec(/^class[\s{]/, fnStr) !== null; + + let kind = 'Function'; + if (isClass) kind = 'Class'; + else if (isAsync && isGenerator) kind = 'AsyncGeneratorFunction'; + else if (isAsync) kind = 'AsyncFunction'; + else if (isGenerator) kind = 'GeneratorFunction'; + + ArrayPrototypePush(lines, + `${style('yellow', kind)}: ${style('bold', value.name || '(anonymous)')}`); + + // Show parameter info from function source. + const match = RegExpPrototypeExec( + /(?:^(?:async\s+)?(?:function\s*\*?\s*)?(\w*)\s*\(([^)]*)\)|^(\w*)\s*=>\s*)/, + fnStr, + ); + if (match) { + const params = (match[2] || '').trim(); + if (params) { + ArrayPrototypePush(lines, + ` ${style('gray', 'Parameters:')} ${style('cyan', params)}`); + } + } + + ArrayPrototypePush(lines, + ` ${style('gray', 'Length:')} ${value.length}`); + + if (value.prototype !== undefined) { + const protoMethods = []; + try { + const protoNames = ObjectGetOwnPropertyNames(value.prototype); + for (let i = 0; i < protoNames.length; i++) { + const pn = protoNames[i]; + if (pn === 'constructor') continue; + const desc = ObjectGetOwnPropertyDescriptor(value.prototype, pn); + if (desc && typeof desc.value === 'function') { + ArrayPrototypePush(protoMethods, pn); + } + } + } catch { + // Some prototypes throw on access; skip. + } + if (protoMethods.length > 0) { + ArrayPrototypePush(lines, + ` ${style('gray', 'Prototype methods:')}`, + ` ${style('magenta', ArrayPrototypeJoin(ArrayPrototypeSort(protoMethods), ', '))}`); + } + } + } else if (type === 'object' || type === 'null' || + type === 'undefined') { + if (value === null) { + ArrayPrototypePush(lines, style('yellow', 'null')); + } else if (value === undefined) { + ArrayPrototypePush(lines, style('yellow', 'undefined')); + } else { + const ctor = value.constructor?.name || 'Object'; + ArrayPrototypePush(lines, + `${style('yellow', ctor)} {`); + + // Categorize own properties. + const methods = []; + const gettersSetters = []; + const properties = []; + + try { + const ownNames = ObjectGetOwnPropertyNames(value); + for (let i = 0; i < ownNames.length; i++) { + const name = ownNames[i]; + const desc = ObjectGetOwnPropertyDescriptor(value, name); + if (!desc) continue; + if (desc.get || desc.set) { + const accessors = []; + if (desc.get) ArrayPrototypePush(accessors, 'get'); + if (desc.set) ArrayPrototypePush(accessors, 'set'); + ArrayPrototypePush(gettersSetters, + ` ${style('green', name)} [${ArrayPrototypeJoin(accessors, '/')}]`); + } else if (typeof desc.value === 'function') { + ArrayPrototypePush(methods, + ` ${style('magenta', name)}()`); + } else { + const valStr = inspect(desc.value, { + depth: 0, + colors: repl.useColors, + maxStringLength: 40, + }); + ArrayPrototypePush(properties, + ` ${style('cyan', name)}: ${valStr}`); + } + } + } catch { + ArrayPrototypePush(lines, + ` ${style('gray', '(properties not enumerable)')}`); + } + + if (methods.length > 0) { + ArrayPrototypePush(lines, style('gray', ' Methods:')); + for (let i = 0; i < methods.length; i++) { + ArrayPrototypePush(lines, methods[i]); + } + } + if (gettersSetters.length > 0) { + ArrayPrototypePush(lines, style('gray', ' Accessors:')); + for (let i = 0; i < gettersSetters.length; i++) { + ArrayPrototypePush(lines, gettersSetters[i]); + } + } + if (properties.length > 0) { + ArrayPrototypePush(lines, style('gray', ' Properties:')); + for (let i = 0; i < properties.length; i++) { + ArrayPrototypePush(lines, properties[i]); + } + } + + ArrayPrototypePush(lines, '}'); + + // Show prototype chain. + const chain = []; + let proto = ObjectGetPrototypeOf(value); + while (proto && proto !== ObjectGetPrototypeOf({})) { + const protoName = proto.constructor?.name || '(anonymous)'; + ArrayPrototypePush(chain, protoName); + proto = ObjectGetPrototypeOf(proto); + } + if (chain.length > 0) { + ArrayPrototypePush(lines, + `${style('gray', 'Prototype chain:')} ${ArrayPrototypeJoin(chain, ' -> ')}`); + } + } + } else { + // Primitive: string, number, boolean, bigint, symbol. + ArrayPrototypePush(lines, + `${style('yellow', type)}: ${inspect(value, { colors: repl.useColors })}`); + } + + repl.output.write(ArrayPrototypeJoin(lines, '\n') + '\n'); + return undefined; + } + + // Custom inspect so typing `help` alone (without parens) shows a hint. + ObjectDefineProperty(help, SymbolFor('nodejs.util.inspect.custom'), { + __proto__: null, + value: () => '[Function: help] -- call help() or help(value)', + }); + + return help; +} + module.exports = { REPL_MODE_SLOPPY: Symbol('repl-sloppy'), REPL_MODE_STRICT, @@ -856,4 +1084,5 @@ module.exports = { getReplBuiltinLibs, setReplBuiltinLibs, fixReplRequire, + createREPLHelp, }; diff --git a/lib/repl.js b/lib/repl.js index a00b7b3f372f38..9d439f1b2b8513 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -153,6 +153,7 @@ const { getReplBuiltinLibs, setReplBuiltinLibs, fixReplRequire, + createREPLHelp, } = require('internal/repl/utils'); const { complete, @@ -1212,6 +1213,15 @@ class REPLServer extends Interface { // Allow REPL extensions to extend the new context this.emit('reset', this.context); + + // Provide a `help()` function for interactive introspection. + ObjectDefineProperty(this.context, 'help', { + __proto__: null, + configurable: true, + writable: true, + enumerable: false, + value: createREPLHelp(this), + }); } displayPrompt(preserveCursor) { let prompt = this._initialPrompt; diff --git a/test/parallel/test-repl-help.js b/test/parallel/test-repl-help.js new file mode 100644 index 00000000000000..51feadf3754c73 --- /dev/null +++ b/test/parallel/test-repl-help.js @@ -0,0 +1,334 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +const assert = require('assert'); +const REPL = require('internal/repl'); +const stream = require('stream'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Helper to create a REPL and capture output. +function createREPL() { + return new Promise((resolve) => { + const input = new stream.PassThrough(); + const output = new stream.PassThrough(); + let outputData = ''; + output.on('data', (chunk) => { outputData += chunk; }); + + REPL.createInternalRepl( + process.env, + { + input, + output, + useColors: false, + terminal: false, + historySize: 0, + useGlobal: false, + }, + common.mustSucceed((repl) => { + resolve({ + repl, + input, + output, + getOutput() { return outputData; }, + resetOutput() { outputData = ''; }, + }); + }), + ); + }); +} + +(async () => { + // Test 1: `help` is defined in the REPL context. + { + const { repl } = await createREPL(); + assert.strictEqual(typeof repl.context.help, 'function'); + repl.close(); + } + + // Test 2: `help()` with no arguments prints REPL guide. + { + const { repl, getOutput } = await createREPL(); + repl.context.help(); + const out = getOutput(); + assert.ok(out.includes('Node.js REPL Help')); + assert.ok(out.includes('.help')); + assert.ok(out.includes('Ctrl+C')); + assert.ok(out.includes('help(value)')); + repl.close(); + } + + // Test 3: `help(fn)` shows function info. + { + const { repl, getOutput } = await createREPL(); + function greet(name, greeting) { return `${greeting} ${name}`; } + repl.context.help(greet); + const out = getOutput(); + assert.ok(out.includes('Function')); + assert.ok(out.includes('greet')); + assert.ok(out.includes('name, greeting')); + repl.close(); + } + + // Test 4: `help(obj)` categorizes methods and properties. + { + const { repl, getOutput } = await createREPL(); + const obj = { + x: 1, + y: 'hello', + doSomething() { return true; }, + }; + repl.context.help(obj); + const out = getOutput(); + assert.ok(out.includes('Methods:')); + assert.ok(out.includes('doSomething()')); + assert.ok(out.includes('Properties:')); + assert.ok(out.includes('x:')); + repl.close(); + } + + // Test 5: `help(class)` shows class info with prototype methods. + { + const { repl, getOutput } = await createREPL(); + class Animal { + constructor(name) { this.name = name; } + speak() { return `${this.name} speaks`; } + eat() { return 'eating'; } + } + repl.context.help(Animal); + const out = getOutput(); + assert.ok(out.includes('Class')); + assert.ok(out.includes('Animal')); + assert.ok(out.includes('Prototype methods:')); + assert.ok(out.includes('speak')); + assert.ok(out.includes('eat')); + repl.close(); + } + + // Test 6: `help(null)` and `help(undefined)` don't crash. + { + const { repl, getOutput, resetOutput } = await createREPL(); + repl.context.help(null); + assert.ok(getOutput().includes('null')); + resetOutput(); + repl.context.help(undefined); + assert.ok(getOutput().includes('undefined')); + repl.close(); + } + + // Test 7: `help(42)` shows primitive info. + { + const { repl, getOutput } = await createREPL(); + repl.context.help(42); + const out = getOutput(); + assert.ok(out.includes('number')); + assert.ok(out.includes('42')); + repl.close(); + } + + // Test 8: `help(async function)` shows AsyncFunction kind. + { + const { repl, getOutput } = await createREPL(); + async function fetchData(url) { return url; } + repl.context.help(fetchData); + const out = getOutput(); + assert.ok(out.includes('AsyncFunction')); + assert.ok(out.includes('fetchData')); + repl.close(); + } + + // Test 9: help returns undefined (no spurious output in REPL). + { + const { repl } = await createREPL(); + const result = repl.context.help(42); + assert.strictEqual(result, undefined); + repl.close(); + } + + // Test 10: `help` without parens shows custom inspect hint. + { + const { repl } = await createREPL(); + const { inspect } = require('util'); + const inspected = inspect(repl.context.help); + assert.ok(inspected.includes('help()') || + inspected.includes('help(value)')); + repl.close(); + } + + // Test 11: `help(generatorFn)` shows GeneratorFunction kind. + { + const { repl, getOutput } = await createREPL(); + function* gen() { yield 1; } + repl.context.help(gen); + const out = getOutput(); + assert.ok(out.includes('GeneratorFunction')); + assert.ok(out.includes('gen')); + repl.close(); + } + + // Test 12: `help(asyncGeneratorFn)` shows AsyncGeneratorFunction kind. + { + const { repl, getOutput } = await createREPL(); + async function* asyncGen() { yield 1; } + repl.context.help(asyncGen); + const out = getOutput(); + assert.ok(out.includes('AsyncGeneratorFunction')); + assert.ok(out.includes('asyncGen')); + repl.close(); + } + + // Test 13: `help(anonymousFn)` shows '(anonymous)'. + { + const { repl, getOutput } = await createREPL(); + repl.context.help((() => { return function() {}; })()); + const out = getOutput(); + assert.ok(out.includes('Function')); + assert.ok(out.includes('(anonymous)')); + repl.close(); + } + + // Test 14: `help(arrowFn)` — arrow with no prototype. + { + const { repl, getOutput } = await createREPL(); + const arrow = (x) => x * 2; + repl.context.help(arrow); + const out = getOutput(); + assert.ok(out.includes('Function')); + assert.ok(out.includes('arrow')); + // Arrow functions have no .prototype property. + assert.ok(!out.includes('Prototype methods:')); + repl.close(); + } + + // Test 15: `help(obj)` with getters and setters shows Accessors. + { + const { repl, getOutput } = await createREPL(); + const obj = {}; + Object.defineProperty(obj, 'value', { + get() { return 42; }, + set(_v) { /* noop */ }, + enumerable: true, + configurable: true, + }); + Object.defineProperty(obj, 'readOnly', { + get() { return 'hi'; }, + enumerable: true, + configurable: true, + }); + repl.context.help(obj); + const out = getOutput(); + assert.ok(out.includes('Accessors:')); + assert.ok(out.includes('value')); + assert.ok(out.includes('get/set')); + assert.ok(out.includes('readOnly')); + assert.ok(out.includes('get')); + repl.close(); + } + + // Test 16: `help(obj)` shows prototype chain for subclassed objects. + { + const { repl, getOutput } = await createREPL(); + class Base { base() {} } + class Child extends Base { child() {} } + const instance = new Child(); + repl.context.help(instance); + const out = getOutput(); + assert.ok(out.includes('Prototype chain:')); + assert.ok(out.includes('Child')); + assert.ok(out.includes('Base')); + repl.close(); + } + + // Test 17: `help(obj)` handles objects that throw on property access. + { + const { repl, getOutput } = await createREPL(); + const throwing = new Proxy({}, { + getOwnPropertyDescriptor() { throw new Error('no access'); }, + ownKeys() { throw new Error('no keys'); }, + }); + repl.context.help(throwing); + const out = getOutput(); + assert.ok(out.includes('(properties not enumerable)')); + repl.close(); + } + + // Test 18: `help()` with useColors=true exercises the styleText path. + { + const input = new stream.PassThrough(); + const output = new stream.PassThrough(); + let outputData = ''; + output.on('data', (chunk) => { outputData += chunk; }); + const repl = await new Promise((resolve) => { + REPL.createInternalRepl( + process.env, + { + input, + output, + useColors: true, + terminal: false, + historySize: 0, + useGlobal: false, + }, + common.mustSucceed((r) => resolve(r)), + ); + }); + repl.context.help(42); + assert.ok(outputData.includes('number')); + assert.ok(outputData.includes('42')); + // With colors, output should contain ANSI escape codes. + assert.ok(outputData.includes('\x1b[')); + repl.close(); + } + + // Test 19: `help(x => x)` — arrow without parens, no params displayed. + { + const { repl, getOutput } = await createREPL(); + repl.context.help(x => x); + const out = getOutput(); + assert.ok(out.includes('Function')); + // Arrow without parens has no extractable parameter list. + assert.ok(!out.includes('Parameters:')); + repl.close(); + } + + // Test 20: `help(fn)` with no parameters — exercises `if (params)` false. + { + const { repl, getOutput } = await createREPL(); + function noParams() { return 1; } + repl.context.help(noParams); + const out = getOutput(); + assert.ok(out.includes('Function')); + assert.ok(out.includes('noParams')); + assert.ok(!out.includes('Parameters:')); + repl.close(); + } + + // Test 21: `help(Bare)` — function with prototype but no methods on it. + { + const { repl, getOutput } = await createREPL(); + function Bare() {} + repl.context.help(Bare); + const out = getOutput(); + assert.ok(out.includes('Function')); + assert.ok(out.includes('Bare')); + // Prototype only has `constructor`, which is skipped. + assert.ok(!out.includes('Prototype methods:')); + repl.close(); + } + + // Test 22: `help({})` — empty object, all category blocks skipped. + { + const { repl, getOutput } = await createREPL(); + repl.context.help({}); + const out = getOutput(); + assert.ok(out.includes('Object {')); + assert.ok(out.includes('}')); + assert.ok(!out.includes('Methods:')); + assert.ok(!out.includes('Accessors:')); + assert.ok(!out.includes('Properties:')); + repl.close(); + } +})().then(common.mustCall());