Skip to content

Commit 9548210

Browse files
ExE-Bossaduh95
andcommitted
repl: add auto‑completion for dynamic import calls
Refs: #33238 Refs: #33282 Co-authored-by: Antoine du Hamel <[email protected]>
1 parent cfb2e06 commit 9548210

File tree

3 files changed

+245
-4
lines changed

3 files changed

+245
-4
lines changed

lib/internal/modules/esm/get_format.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { extname } = require('path');
77
const { getOptionValue } = require('internal/options');
88

99
const experimentalJsonModules = getOptionValue('--experimental-json-modules');
10-
const experimentalSpeciferResolution =
10+
const experimentalSpecifierResolution =
1111
getOptionValue('--experimental-specifier-resolution');
1212
const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
1313
const { getPackageType } = require('internal/modules/esm/resolve');
@@ -62,7 +62,7 @@ function defaultGetFormat(url, context, defaultGetFormatUnused) {
6262
format = extensionFormatMap[ext];
6363
}
6464
if (!format) {
65-
if (experimentalSpeciferResolution === 'node') {
65+
if (experimentalSpecifierResolution === 'node') {
6666
process.emitWarning(
6767
'The Node.js specifier resolution in ESM is experimental.',
6868
'ExperimentalWarning');
@@ -75,4 +75,9 @@ function defaultGetFormat(url, context, defaultGetFormatUnused) {
7575
}
7676
return { format: null };
7777
}
78-
exports.defaultGetFormat = defaultGetFormat;
78+
79+
module.exports = {
80+
defaultGetFormat,
81+
extensionFormatMap,
82+
legacyExtensionFormatMap,
83+
};

lib/repl.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ const {
5454
ArrayPrototypePush,
5555
ArrayPrototypeReverse,
5656
ArrayPrototypeShift,
57+
ArrayPrototypeSlice,
58+
ArrayPrototypeSome,
5759
ArrayPrototypeSort,
5860
ArrayPrototypeSplice,
5961
ArrayPrototypeUnshift,
@@ -125,6 +127,8 @@ let _builtinLibs = ArrayPrototypeFilter(
125127
CJSModule.builtinModules,
126128
(e) => !StringPrototypeStartsWith(e, '_') && !StringPrototypeIncludes(e, '/')
127129
);
130+
const nodeSchemeBuiltinLibs = ArrayPrototypeMap(
131+
_builtinLibs, (lib) => `node:${lib}`);
128132
const domain = require('domain');
129133
let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
130134
debug = fn;
@@ -170,6 +174,10 @@ const {
170174
} = internalBinding('contextify');
171175

172176
const history = require('internal/repl/history');
177+
const {
178+
extensionFormatMap,
179+
legacyExtensionFormatMap,
180+
} = require('internal/modules/esm/get_format');
173181

174182
let nextREPLResourceNumber = 1;
175183
// This prevents v8 code cache from getting confused and using a different
@@ -1104,10 +1112,12 @@ REPLServer.prototype.setPrompt = function setPrompt(prompt) {
11041112
ReflectApply(Interface.prototype.setPrompt, this, [prompt]);
11051113
};
11061114

1115+
const importRE = /\bimport\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/;
11071116
const requireRE = /\brequire\s*\(\s*['"`](([\w@./-]+\/)?(?:[\w@./-]*))(?![^'"`])$/;
11081117
const fsAutoCompleteRE = /fs(?:\.promises)?\.\s*[a-z][a-zA-Z]+\(\s*["'](.*)/;
11091118
const simpleExpressionRE =
11101119
/(?:[a-zA-Z_$](?:\w|\$)*\??\.)*[a-zA-Z_$](?:\w|\$)*\??\.?$/;
1120+
const versionedFileNamesRe = /-\d+\.\d+/;
11111121

11121122
function isIdentifier(str) {
11131123
if (str === '') {
@@ -1214,7 +1224,6 @@ function complete(line, callback) {
12141224
const indexes = ArrayPrototypeMap(extensions,
12151225
(extension) => `index${extension}`);
12161226
ArrayPrototypePush(indexes, 'package.json', 'index');
1217-
const versionedFileNamesRe = /-\d+\.\d+/;
12181227

12191228
const match = StringPrototypeMatch(line, requireRE);
12201229
completeOn = match[1];
@@ -1269,6 +1278,75 @@ function complete(line, callback) {
12691278
if (!subdir) {
12701279
ArrayPrototypePush(completionGroups, _builtinLibs);
12711280
}
1281+
} else if (RegExpPrototypeTest(importRE, line) &&
1282+
this.allowBlockingCompletions) {
1283+
// import('...<Tab>')
1284+
// File extensions that can be imported:
1285+
const extensions = ObjectKeys(
1286+
getOptionValue('--experimental-specifier-resolution') === 'node' ?
1287+
legacyExtensionFormatMap :
1288+
extensionFormatMap);
1289+
1290+
// Only used when loading bare module specifiers from `node_modules`:
1291+
const indexes = ArrayPrototypeMap(extensions, (ext) => `index${ext}`);
1292+
ArrayPrototypePush(indexes, 'package.json');
1293+
1294+
const match = StringPrototypeMatch(line, importRE);
1295+
completeOn = match[1];
1296+
const subdir = match[2] || '';
1297+
filter = completeOn;
1298+
group = [];
1299+
let paths = [];
1300+
if (completeOn === '.') {
1301+
group = ['./', '../'];
1302+
} else if (completeOn === '..') {
1303+
group = ['../'];
1304+
} else if (RegExpPrototypeTest(/^\.\.?\//, completeOn)) {
1305+
paths = [process.cwd()];
1306+
} else {
1307+
paths = ArrayPrototypeSlice(module.paths);
1308+
}
1309+
1310+
ArrayPrototypeForEach(paths, (dir) => {
1311+
dir = path.resolve(dir, subdir);
1312+
const isInNodeModules = path.basename(dir) === 'node_modules';
1313+
const dirents = gracefulReaddir(dir, { withFileTypes: true }) || [];
1314+
ArrayPrototypeForEach(dirents, (dirent) => {
1315+
const { name } = dirent;
1316+
if (RegExpPrototypeTest(versionedFileNamesRe, name) ||
1317+
name === '.npm') {
1318+
// Exclude versioned names that 'npm' installs.
1319+
return;
1320+
}
1321+
1322+
if (!dirent.isDirectory()) {
1323+
const extension = path.extname(name);
1324+
if (StringPrototypeIncludes(extensions, extension)) {
1325+
ArrayPrototypePush(group, `${subdir}${name}`);
1326+
}
1327+
return;
1328+
}
1329+
1330+
ArrayPrototypePush(group, `${subdir}${name}/`);
1331+
if (!subdir && isInNodeModules) {
1332+
const absolute = path.resolve(dir, name);
1333+
const subfiles = gracefulReaddir(absolute) || [];
1334+
if (ArrayPrototypeSome(subfiles, (subfile) => {
1335+
return ArrayPrototypeIncludes(indexes, subfile);
1336+
})) {
1337+
ArrayPrototypePush(group, `${subdir}${name}`);
1338+
}
1339+
}
1340+
});
1341+
});
1342+
1343+
if (group.length) {
1344+
ArrayPrototypePush(completionGroups, group);
1345+
}
1346+
1347+
if (!subdir) {
1348+
ArrayPrototypePush(completionGroups, _builtinLibs, nodeSchemeBuiltinLibs);
1349+
}
12721350
} else if (RegExpPrototypeTest(fsAutoCompleteRE, line) &&
12731351
this.allowBlockingCompletions) {
12741352
({ 0: completionGroups, 1: completeOn } = completeFSFunctions(line));
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const ArrayStream = require('../common/arraystream');
5+
const fixtures = require('../common/fixtures');
6+
const assert = require('assert');
7+
const { builtinModules } = require('module');
8+
const publicModules = builtinModules.filter(
9+
(lib) => !lib.startsWith('_') && !lib.includes('/'),
10+
);
11+
12+
if (!common.isMainThread)
13+
common.skip('process.chdir is not available in Workers');
14+
15+
// We have to change the directory to ../fixtures before requiring repl
16+
// in order to make the tests for completion of node_modules work properly
17+
// since repl modifies module.paths.
18+
process.chdir(fixtures.fixturesDir);
19+
20+
const repl = require('repl');
21+
22+
const putIn = new ArrayStream();
23+
const testMe = repl.start({
24+
prompt: '',
25+
input: putIn,
26+
output: process.stdout,
27+
allowBlockingCompletions: true
28+
});
29+
30+
// Some errors are passed to the domain, but do not callback
31+
testMe._domain.on('error', assert.ifError);
32+
33+
// Tab complete provides built in libs for import()
34+
testMe.complete('import(\'', common.mustCall((error, data) => {
35+
assert.strictEqual(error, null);
36+
publicModules.forEach((lib) => {
37+
assert(
38+
data[0].includes(lib) && data[0].includes(`node:${lib}`),
39+
`${lib} not found`,
40+
);
41+
});
42+
const newModule = 'foobar';
43+
assert(!builtinModules.includes(newModule));
44+
repl.builtinModules.push(newModule);
45+
testMe.complete('import(\'', common.mustCall((_, [modules]) => {
46+
assert.strictEqual(data[0].length + 1, modules.length);
47+
assert(modules.includes(newModule) &&
48+
!modules.includes(`node:${newModule}`));
49+
}));
50+
}));
51+
52+
testMe.complete("import\t( 'n", common.mustCall((error, data) => {
53+
assert.strictEqual(error, null);
54+
assert.strictEqual(data.length, 2);
55+
assert.strictEqual(data[1], 'n');
56+
const completions = data[0];
57+
// import(...) completions include `node:` URL modules:
58+
publicModules.forEach((lib, index) =>
59+
assert.strictEqual(completions[index], `node:${lib}`));
60+
assert.strictEqual(completions[publicModules.length], '');
61+
// There is only one Node.js module that starts with n:
62+
assert.strictEqual(completions[publicModules.length + 1], 'net');
63+
assert.strictEqual(completions[publicModules.length + 2], '');
64+
// It's possible to pick up non-core modules too
65+
completions.slice(publicModules.length + 3).forEach((completion) => {
66+
assert.match(completion, /^n/);
67+
});
68+
}));
69+
70+
{
71+
const expected = ['@nodejsscope', '@nodejsscope/'];
72+
// Import calls should handle all types of quotation marks.
73+
for (const quotationMark of ["'", '"', '`']) {
74+
putIn.run(['.clear']);
75+
testMe.complete('import(`@nodejs', common.mustCall((err, data) => {
76+
assert.strictEqual(err, null);
77+
assert.deepStrictEqual(data, [expected, '@nodejs']);
78+
}));
79+
80+
putIn.run(['.clear']);
81+
// Completions should not be greedy in case the quotation ends.
82+
const input = `import(${quotationMark}@nodejsscope${quotationMark}`;
83+
testMe.complete(input, common.mustCall((err, data) => {
84+
assert.strictEqual(err, null);
85+
assert.deepStrictEqual(data, [[], undefined]);
86+
}));
87+
}
88+
}
89+
90+
{
91+
putIn.run(['.clear']);
92+
// Completions should find modules and handle whitespace after the opening
93+
// bracket.
94+
testMe.complete('import \t("no_ind', common.mustCall((err, data) => {
95+
assert.strictEqual(err, null);
96+
assert.deepStrictEqual(data, [['no_index', 'no_index/'], 'no_ind']);
97+
}));
98+
}
99+
100+
// Test tab completion for import() relative to the current directory
101+
{
102+
putIn.run(['.clear']);
103+
104+
const cwd = process.cwd();
105+
process.chdir(__dirname);
106+
107+
['import(\'.', 'import(".'].forEach((input) => {
108+
testMe.complete(input, common.mustCall((err, data) => {
109+
assert.strictEqual(err, null);
110+
assert.strictEqual(data.length, 2);
111+
assert.strictEqual(data[1], '.');
112+
assert.strictEqual(data[0].length, 2);
113+
assert.ok(data[0].includes('./'));
114+
assert.ok(data[0].includes('../'));
115+
}));
116+
});
117+
118+
['import(\'..', 'import("..'].forEach((input) => {
119+
testMe.complete(input, common.mustCall((err, data) => {
120+
assert.strictEqual(err, null);
121+
assert.deepStrictEqual(data, [['../'], '..']);
122+
}));
123+
});
124+
125+
['./', './test-'].forEach((path) => {
126+
[`import('${path}`, `import("${path}`].forEach((input) => {
127+
testMe.complete(input, common.mustCall((err, data) => {
128+
assert.strictEqual(err, null);
129+
assert.strictEqual(data.length, 2);
130+
assert.strictEqual(data[1], path);
131+
assert.ok(data[0].includes('./test-repl-tab-complete.js'));
132+
}));
133+
});
134+
});
135+
136+
['../parallel/', '../parallel/test-'].forEach((path) => {
137+
[`import('${path}`, `import("${path}`].forEach((input) => {
138+
testMe.complete(input, common.mustCall((err, data) => {
139+
assert.strictEqual(err, null);
140+
assert.strictEqual(data.length, 2);
141+
assert.strictEqual(data[1], path);
142+
assert.ok(data[0].includes('../parallel/test-repl-tab-complete.js'));
143+
}));
144+
});
145+
});
146+
147+
{
148+
const path = '../fixtures/repl-folder-extensions/f';
149+
testMe.complete(`import('${path}`, common.mustSucceed((data) => {
150+
assert.strictEqual(data.length, 2);
151+
assert.strictEqual(data[1], path);
152+
assert.ok(data[0].includes(
153+
'../fixtures/repl-folder-extensions/foo.js/'));
154+
}));
155+
}
156+
157+
process.chdir(cwd);
158+
}

0 commit comments

Comments
 (0)