Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repl: add auto‑completion for dynamic import calls #37178

Merged
merged 1 commit into from
Feb 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -75,4 +75,9 @@ function defaultGetFormat(url, context, defaultGetFormatUnused) {
}
return { format: null };
}
exports.defaultGetFormat = defaultGetFormat;

module.exports = {
defaultGetFormat,
extensionFormatMap,
legacyExtensionFormatMap,
};
79 changes: 78 additions & 1 deletion lib/repl.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const {
ArrayPrototypePush,
ArrayPrototypeReverse,
ArrayPrototypeShift,
ArrayPrototypeSlice,
ArrayPrototypeSome,
ArrayPrototypeSort,
ArrayPrototypeSplice,
Expand Down Expand Up @@ -126,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;
Expand Down Expand Up @@ -171,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
Expand Down Expand Up @@ -1105,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 === '') {
Expand Down Expand Up @@ -1215,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];
Expand Down Expand Up @@ -1269,6 +1277,75 @@ function complete(line, callback) {
if (!subdir) {
ArrayPrototypePush(completionGroups, _builtinLibs);
}
} else if (RegExpPrototypeTest(importRE, line) &&
this.allowBlockingCompletions) {
// import('...<Tab>')
// 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));
Expand Down
3 changes: 3 additions & 0 deletions test/parallel/test-repl-autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ const tests = [
yield 'require("./';
yield TABULATION;
yield SIGINT;
yield 'import("./';
yield TABULATION;
yield SIGINT;
yield 'Array.proto';
yield RIGHT;
yield '.pu';
Expand Down
158 changes: 158 additions & 0 deletions test/parallel/test-repl-tab-complete-import.js
Original file line number Diff line number Diff line change
@@ -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);
}