From 95482100564a9fff5cc2409accf2729cce3de85c Mon Sep 17 00:00:00 2001 From: ExE Boss <3889017+ExE-Boss@users.noreply.github.com> Date: Tue, 2 Feb 2021 02:10:00 +0100 Subject: [PATCH] =?UTF-8?q?repl:=20add=C2=A0auto=E2=80=91completion=20for?= =?UTF-8?q?=C2=A0dynamic=C2=A0import=C2=A0calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: https://github.com/nodejs/node/issues/33238 Refs: https://github.com/nodejs/node/pull/33282 Co-authored-by: Antoine du Hamel --- lib/internal/modules/esm/get_format.js | 11 +- lib/repl.js | 80 ++++++++- .../parallel/test-repl-tab-complete-import.js | 158 ++++++++++++++++++ 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-repl-tab-complete-import.js diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index b48741c422c47f..f02bb5cde70772 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -7,7 +7,7 @@ const { extname } = require('path'); const { getOptionValue } = require('internal/options'); const experimentalJsonModules = getOptionValue('--experimental-json-modules'); -const experimentalSpeciferResolution = +const experimentalSpecifierResolution = getOptionValue('--experimental-specifier-resolution'); const experimentalWasmModules = getOptionValue('--experimental-wasm-modules'); const { getPackageType } = require('internal/modules/esm/resolve'); @@ -62,7 +62,7 @@ function defaultGetFormat(url, context, defaultGetFormatUnused) { format = extensionFormatMap[ext]; } if (!format) { - if (experimentalSpeciferResolution === 'node') { + if (experimentalSpecifierResolution === 'node') { process.emitWarning( 'The Node.js specifier resolution in ESM is experimental.', 'ExperimentalWarning'); @@ -75,4 +75,9 @@ function defaultGetFormat(url, context, defaultGetFormatUnused) { } return { format: null }; } -exports.defaultGetFormat = defaultGetFormat; + +module.exports = { + defaultGetFormat, + extensionFormatMap, + legacyExtensionFormatMap, +}; diff --git a/lib/repl.js b/lib/repl.js index 09494c3f2fcf15..7141698d6ceca0 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -54,6 +54,8 @@ const { ArrayPrototypePush, ArrayPrototypeReverse, ArrayPrototypeShift, + ArrayPrototypeSlice, + ArrayPrototypeSome, ArrayPrototypeSort, ArrayPrototypeSplice, ArrayPrototypeUnshift, @@ -125,6 +127,8 @@ let _builtinLibs = ArrayPrototypeFilter( CJSModule.builtinModules, (e) => !StringPrototypeStartsWith(e, '_') && !StringPrototypeIncludes(e, '/') ); +const nodeSchemeBuiltinLibs = ArrayPrototypeMap( + _builtinLibs, (lib) => `node:${lib}`); const domain = require('domain'); let debug = require('internal/util/debuglog').debuglog('repl', (fn) => { debug = fn; @@ -170,6 +174,10 @@ const { } = internalBinding('contextify'); const history = require('internal/repl/history'); +const { + extensionFormatMap, + legacyExtensionFormatMap, +} = require('internal/modules/esm/get_format'); let nextREPLResourceNumber = 1; // This prevents v8 code cache from getting confused and using a different @@ -1104,10 +1112,12 @@ REPLServer.prototype.setPrompt = function setPrompt(prompt) { ReflectApply(Interface.prototype.setPrompt, this, [prompt]); }; +const importRE = /\bimport\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/; const requireRE = /\brequire\s*\(\s*['"`](([\w@./-]+\/)?(?:[\w@./-]*))(?![^'"`])$/; const fsAutoCompleteRE = /fs(?:\.promises)?\.\s*[a-z][a-zA-Z]+\(\s*["'](.*)/; const simpleExpressionRE = /(?:[a-zA-Z_$](?:\w|\$)*\??\.)*[a-zA-Z_$](?:\w|\$)*\??\.?$/; +const versionedFileNamesRe = /-\d+\.\d+/; function isIdentifier(str) { if (str === '') { @@ -1214,7 +1224,6 @@ function complete(line, callback) { const indexes = ArrayPrototypeMap(extensions, (extension) => `index${extension}`); ArrayPrototypePush(indexes, 'package.json', 'index'); - const versionedFileNamesRe = /-\d+\.\d+/; const match = StringPrototypeMatch(line, requireRE); completeOn = match[1]; @@ -1269,6 +1278,75 @@ function complete(line, callback) { if (!subdir) { ArrayPrototypePush(completionGroups, _builtinLibs); } + } else if (RegExpPrototypeTest(importRE, line) && + this.allowBlockingCompletions) { + // import('...') + // File extensions that can be imported: + const extensions = ObjectKeys( + getOptionValue('--experimental-specifier-resolution') === 'node' ? + legacyExtensionFormatMap : + extensionFormatMap); + + // Only used when loading bare module specifiers from `node_modules`: + const indexes = ArrayPrototypeMap(extensions, (ext) => `index${ext}`); + ArrayPrototypePush(indexes, 'package.json'); + + const match = StringPrototypeMatch(line, importRE); + completeOn = match[1]; + const subdir = match[2] || ''; + filter = completeOn; + group = []; + let paths = []; + if (completeOn === '.') { + group = ['./', '../']; + } else if (completeOn === '..') { + group = ['../']; + } else if (RegExpPrototypeTest(/^\.\.?\//, completeOn)) { + paths = [process.cwd()]; + } else { + paths = ArrayPrototypeSlice(module.paths); + } + + ArrayPrototypeForEach(paths, (dir) => { + dir = path.resolve(dir, subdir); + const isInNodeModules = path.basename(dir) === 'node_modules'; + const dirents = gracefulReaddir(dir, { withFileTypes: true }) || []; + ArrayPrototypeForEach(dirents, (dirent) => { + const { name } = dirent; + if (RegExpPrototypeTest(versionedFileNamesRe, name) || + name === '.npm') { + // Exclude versioned names that 'npm' installs. + return; + } + + if (!dirent.isDirectory()) { + const extension = path.extname(name); + if (StringPrototypeIncludes(extensions, extension)) { + ArrayPrototypePush(group, `${subdir}${name}`); + } + return; + } + + ArrayPrototypePush(group, `${subdir}${name}/`); + if (!subdir && isInNodeModules) { + const absolute = path.resolve(dir, name); + const subfiles = gracefulReaddir(absolute) || []; + if (ArrayPrototypeSome(subfiles, (subfile) => { + return ArrayPrototypeIncludes(indexes, subfile); + })) { + ArrayPrototypePush(group, `${subdir}${name}`); + } + } + }); + }); + + if (group.length) { + ArrayPrototypePush(completionGroups, group); + } + + if (!subdir) { + ArrayPrototypePush(completionGroups, _builtinLibs, nodeSchemeBuiltinLibs); + } } else if (RegExpPrototypeTest(fsAutoCompleteRE, line) && this.allowBlockingCompletions) { ({ 0: completionGroups, 1: completeOn } = completeFSFunctions(line)); diff --git a/test/parallel/test-repl-tab-complete-import.js b/test/parallel/test-repl-tab-complete-import.js new file mode 100644 index 00000000000000..414b5cc4eac103 --- /dev/null +++ b/test/parallel/test-repl-tab-complete-import.js @@ -0,0 +1,158 @@ +'use strict'; + +const common = require('../common'); +const ArrayStream = require('../common/arraystream'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const { builtinModules } = require('module'); +const publicModules = builtinModules.filter( + (lib) => !lib.startsWith('_') && !lib.includes('/'), +); + +if (!common.isMainThread) + common.skip('process.chdir is not available in Workers'); + +// We have to change the directory to ../fixtures before requiring repl +// in order to make the tests for completion of node_modules work properly +// since repl modifies module.paths. +process.chdir(fixtures.fixturesDir); + +const repl = require('repl'); + +const putIn = new ArrayStream(); +const testMe = repl.start({ + prompt: '', + input: putIn, + output: process.stdout, + allowBlockingCompletions: true +}); + +// Some errors are passed to the domain, but do not callback +testMe._domain.on('error', assert.ifError); + +// Tab complete provides built in libs for import() +testMe.complete('import(\'', common.mustCall((error, data) => { + assert.strictEqual(error, null); + publicModules.forEach((lib) => { + assert( + data[0].includes(lib) && data[0].includes(`node:${lib}`), + `${lib} not found`, + ); + }); + const newModule = 'foobar'; + assert(!builtinModules.includes(newModule)); + repl.builtinModules.push(newModule); + testMe.complete('import(\'', common.mustCall((_, [modules]) => { + assert.strictEqual(data[0].length + 1, modules.length); + assert(modules.includes(newModule) && + !modules.includes(`node:${newModule}`)); + })); +})); + +testMe.complete("import\t( 'n", common.mustCall((error, data) => { + assert.strictEqual(error, null); + assert.strictEqual(data.length, 2); + assert.strictEqual(data[1], 'n'); + const completions = data[0]; + // import(...) completions include `node:` URL modules: + publicModules.forEach((lib, index) => + assert.strictEqual(completions[index], `node:${lib}`)); + assert.strictEqual(completions[publicModules.length], ''); + // There is only one Node.js module that starts with n: + assert.strictEqual(completions[publicModules.length + 1], 'net'); + assert.strictEqual(completions[publicModules.length + 2], ''); + // It's possible to pick up non-core modules too + completions.slice(publicModules.length + 3).forEach((completion) => { + assert.match(completion, /^n/); + }); +})); + +{ + const expected = ['@nodejsscope', '@nodejsscope/']; + // Import calls should handle all types of quotation marks. + for (const quotationMark of ["'", '"', '`']) { + putIn.run(['.clear']); + testMe.complete('import(`@nodejs', common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(data, [expected, '@nodejs']); + })); + + putIn.run(['.clear']); + // Completions should not be greedy in case the quotation ends. + const input = `import(${quotationMark}@nodejsscope${quotationMark}`; + testMe.complete(input, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(data, [[], undefined]); + })); + } +} + +{ + putIn.run(['.clear']); + // Completions should find modules and handle whitespace after the opening + // bracket. + testMe.complete('import \t("no_ind', common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(data, [['no_index', 'no_index/'], 'no_ind']); + })); +} + +// Test tab completion for import() relative to the current directory +{ + putIn.run(['.clear']); + + const cwd = process.cwd(); + process.chdir(__dirname); + + ['import(\'.', 'import(".'].forEach((input) => { + testMe.complete(input, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data.length, 2); + assert.strictEqual(data[1], '.'); + assert.strictEqual(data[0].length, 2); + assert.ok(data[0].includes('./')); + assert.ok(data[0].includes('../')); + })); + }); + + ['import(\'..', 'import("..'].forEach((input) => { + testMe.complete(input, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(data, [['../'], '..']); + })); + }); + + ['./', './test-'].forEach((path) => { + [`import('${path}`, `import("${path}`].forEach((input) => { + testMe.complete(input, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data.length, 2); + assert.strictEqual(data[1], path); + assert.ok(data[0].includes('./test-repl-tab-complete.js')); + })); + }); + }); + + ['../parallel/', '../parallel/test-'].forEach((path) => { + [`import('${path}`, `import("${path}`].forEach((input) => { + testMe.complete(input, common.mustCall((err, data) => { + assert.strictEqual(err, null); + assert.strictEqual(data.length, 2); + assert.strictEqual(data[1], path); + assert.ok(data[0].includes('../parallel/test-repl-tab-complete.js')); + })); + }); + }); + + { + const path = '../fixtures/repl-folder-extensions/f'; + testMe.complete(`import('${path}`, common.mustSucceed((data) => { + assert.strictEqual(data.length, 2); + assert.strictEqual(data[1], path); + assert.ok(data[0].includes( + '../fixtures/repl-folder-extensions/foo.js/')); + })); + } + + process.chdir(cwd); +}