From 3aba6fef07a197ec16ba39ab97f080478a9b0f0b 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 <duhamelantoine1995@gmail.com>

PR-URL: https://github.com/nodejs/node/pull/37178
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Juan José Arboleda <soyjuanarbol@gmail.com>
Reviewed-By: Rich Trott <rtrott@gmail.com>
---
 lib/internal/modules/esm/get_format.js        |  11 +-
 lib/repl.js                                   |  79 ++++++++-
 test/parallel/test-repl-autocomplete.js       |   3 +
 .../parallel/test-repl-tab-complete-import.js | 158 ++++++++++++++++++
 4 files changed, 247 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 d5d9371de34428..acbfe8a9b709ae 100644
--- a/lib/repl.js
+++ b/lib/repl.js
@@ -54,6 +54,7 @@ const {
   ArrayPrototypePush,
   ArrayPrototypeReverse,
   ArrayPrototypeShift,
+  ArrayPrototypeSlice,
   ArrayPrototypeSome,
   ArrayPrototypeSort,
   ArrayPrototypeSplice,
@@ -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;
@@ -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
@@ -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 === '') {
@@ -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];
@@ -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));
diff --git a/test/parallel/test-repl-autocomplete.js b/test/parallel/test-repl-autocomplete.js
index e1d2189fdb17cb..b107053183080a 100644
--- a/test/parallel/test-repl-autocomplete.js
+++ b/test/parallel/test-repl-autocomplete.js
@@ -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';
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);
+}