Skip to content

Commit

Permalink
repl: add auto‑completion for dynamic import calls
Browse files Browse the repository at this point in the history
Refs: #33238
Refs: #33282

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

PR-URL: #37178
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Juan José Arboleda <soyjuanarbol@gmail.com>
Reviewed-By: Rich Trott <rtrott@gmail.com>
  • Loading branch information
ExE-Boss authored and danielleadams committed Feb 16, 2021
1 parent c302450 commit 3fee5b2
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 4 deletions.
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);
}

0 comments on commit 3fee5b2

Please sign in to comment.