Skip to content

Commit 47548d9

Browse files
aduh95RafaelGSS
authored andcommitted
esm: fix hint on invalid module specifier
PR-URL: #51223 Fixes: #51216 Reviewed-By: Geoffrey Booth <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent 8412751 commit 47548d9

File tree

7 files changed

+66
-31
lines changed

7 files changed

+66
-31
lines changed

lib/internal/modules/esm/resolve.js

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
const {
44
ArrayIsArray,
55
ArrayPrototypeJoin,
6-
ArrayPrototypeShift,
6+
ArrayPrototypeMap,
77
JSONStringify,
88
ObjectGetOwnPropertyNames,
99
ObjectPrototypeHasOwnProperty,
10-
RegExp,
1110
RegExpPrototypeExec,
1211
RegExpPrototypeSymbolReplace,
1312
SafeMap,
@@ -21,6 +20,7 @@ const {
2120
StringPrototypeSlice,
2221
StringPrototypeSplit,
2322
StringPrototypeStartsWith,
23+
encodeURIComponent,
2424
} = primordials;
2525
const internalFS = require('internal/fs/utils');
2626
const { BuiltinModule } = require('internal/bootstrap/realm');
@@ -30,7 +30,7 @@ const { getOptionValue } = require('internal/options');
3030
const policy = getOptionValue('--experimental-policy') ?
3131
require('internal/process/policy') :
3232
null;
33-
const { sep, relative, toNamespacedPath, resolve } = require('path');
33+
const { sep, posix: { relative: relativePosixPath }, toNamespacedPath, resolve } = require('path');
3434
const preserveSymlinks = getOptionValue('--preserve-symlinks');
3535
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
3636
const experimentalNetworkImports =
@@ -912,6 +912,7 @@ function moduleResolve(specifier, base, conditions, preserveSymlinks) {
912912
* Try to resolve an import as a CommonJS module.
913913
* @param {string} specifier - The specifier to resolve.
914914
* @param {string} parentURL - The base URL.
915+
* @returns {string | Buffer | false}
915916
*/
916917
function resolveAsCommonJS(specifier, parentURL) {
917918
try {
@@ -924,29 +925,38 @@ function resolveAsCommonJS(specifier, parentURL) {
924925
// If it is a relative specifier return the relative path
925926
// to the parent
926927
if (isRelativeSpecifier(specifier)) {
927-
found = relative(parent, found);
928-
// Add '.separator if the path does not start with '..separator'
928+
const foundURL = pathToFileURL(found).pathname;
929+
found = relativePosixPath(
930+
StringPrototypeSlice(parentURL, 'file://'.length, StringPrototypeLastIndexOf(parentURL, '/')),
931+
foundURL);
932+
933+
// Add './' if the path does not start with '../'
929934
// This should be a safe assumption because when loading
930935
// esm modules there should be always a file specified so
931936
// there should not be a specifier like '..' or '.'
932-
if (!StringPrototypeStartsWith(found, `..${sep}`)) {
933-
found = `.${sep}${found}`;
937+
if (!StringPrototypeStartsWith(found, '../')) {
938+
found = `./${found}`;
934939
}
935940
} else if (isBareSpecifier(specifier)) {
936941
// If it is a bare specifier return the relative path within the
937942
// module
938-
const pkg = StringPrototypeSplit(specifier, '/')[0];
939-
const index = StringPrototypeIndexOf(found, pkg);
943+
const i = StringPrototypeIndexOf(specifier, '/');
944+
const pkg = i === -1 ? specifier : StringPrototypeSlice(specifier, 0, i);
945+
const needle = `${sep}node_modules${sep}${pkg}${sep}`;
946+
const index = StringPrototypeLastIndexOf(found, needle);
940947
if (index !== -1) {
941-
found = StringPrototypeSlice(found, index);
948+
found = pkg + '/' + ArrayPrototypeJoin(
949+
ArrayPrototypeMap(
950+
StringPrototypeSplit(StringPrototypeSlice(found, index + needle.length), sep),
951+
// Escape URL-special characters to avoid generating a incorrect suggestion
952+
encodeURIComponent,
953+
),
954+
'/',
955+
);
956+
} else {
957+
found = `${pathToFileURL(found)}`;
942958
}
943959
}
944-
// Normalize the path separator to give a valid suggestion
945-
// on Windows
946-
if (process.platform === 'win32') {
947-
found = RegExpPrototypeSymbolReplace(new RegExp(`\\${sep}`, 'g'),
948-
found, '/');
949-
}
950960
return found;
951961
} catch {
952962
return false;
@@ -1154,14 +1164,14 @@ function defaultResolve(specifier, context = {}) {
11541164
*/
11551165
function decorateErrorWithCommonJSHints(error, specifier, parentURL) {
11561166
const found = resolveAsCommonJS(specifier, parentURL);
1157-
if (found) {
1167+
if (found && found !== specifier) { // Don't suggest the same input the user provided.
11581168
// Modify the stack and message string to include the hint
1159-
const lines = StringPrototypeSplit(error.stack, '\n');
1160-
const hint = `Did you mean to import ${found}?`;
1169+
const endOfFirstLine = StringPrototypeIndexOf(error.stack, '\n');
1170+
const hint = `Did you mean to import ${JSONStringify(found)}?`;
11611171
error.stack =
1162-
ArrayPrototypeShift(lines) + '\n' +
1163-
hint + '\n' +
1164-
ArrayPrototypeJoin(lines, '\n');
1172+
StringPrototypeSlice(error.stack, 0, endOfFirstLine) + '\n' +
1173+
hint +
1174+
StringPrototypeSlice(error.stack, endOfFirstLine);
11651175
error.message += `\n${hint}`;
11661176
}
11671177
}

test/es-module/test-esm-module-not-found-commonjs-hint.mjs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,53 @@
11
import { spawnPromisified } from '../common/index.mjs';
2-
import { fixturesDir } from '../common/fixtures.mjs';
2+
import { fixturesDir, fileURL as fixtureSubDir } from '../common/fixtures.mjs';
33
import { match, notStrictEqual } from 'node:assert';
44
import { execPath } from 'node:process';
55
import { describe, it } from 'node:test';
66

77

88
describe('ESM: module not found hint', { concurrency: true }, () => {
99
for (
10-
const { input, expected }
10+
const { input, expected, cwd = fixturesDir }
1111
of [
1212
{
1313
input: 'import "./print-error-message"',
14-
// Did you mean to import ../print-error-message.js?
15-
expected: / \.\.\/print-error-message\.js\?/,
14+
// Did you mean to import "./print-error-message.js"?
15+
expected: / "\.\/print-error-message\.js"\?/,
16+
},
17+
{
18+
input: 'import "./es-modules/folder%25with percentage#/index.js"',
19+
// Did you mean to import "./es-modules/folder%2525with%20percentage%23/index.js"?
20+
expected: / "\.\/es-modules\/folder%2525with%20percentage%23\/index\.js"\?/,
21+
},
22+
{
23+
input: 'import "../folder%25with percentage#/index.js"',
24+
// Did you mean to import "../es-modules/folder%2525with%20percentage%23/index.js"?
25+
expected: / "\.\.\/folder%2525with%20percentage%23\/index\.js"\?/,
26+
cwd: fixtureSubDir('es-modules/tla/'),
1627
},
1728
{
1829
input: 'import obj from "some_module/obj"',
19-
expected: / some_module\/obj\.js\?/,
30+
expected: / "some_module\/obj\.js"\?/,
31+
},
32+
{
33+
input: 'import obj from "some_module/folder%25with percentage#/index.js"',
34+
expected: / "some_module\/folder%2525with%20percentage%23\/index\.js"\?/,
35+
},
36+
{
37+
input: 'import "@nodejsscope/pkg/index"',
38+
expected: / "@nodejsscope\/pkg\/index\.js"\?/,
39+
},
40+
{
41+
input: 'import obj from "lone_file.js"',
42+
expected: /node_modules\/lone_file\.js"\?/,
2043
},
2144
]
2245
) it('should cite a variant form', async () => {
2346
const { code, stderr } = await spawnPromisified(execPath, [
2447
'--input-type=module',
2548
'--eval',
2649
input,
27-
], {
28-
cwd: fixturesDir,
29-
});
50+
], { cwd });
3051

3152
match(stderr, expected);
3253
notStrictEqual(code, 0);

test/es-module/test-esm-type-flag-cli-entry.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('--experimental-default-type=module should not support extension search
2626
cwd: fixtures.path('es-modules/package-without-type'),
2727
});
2828

29-
match(stderr, /ENOENT.*Did you mean to import .*index\.js\?/s);
29+
match(stderr, /ENOENT.*Did you mean to import .*index\.js"\?/s);
3030
strictEqual(stdout, '');
3131
strictEqual(code, 1);
3232
strictEqual(signal, null);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'use strict';

test/fixtures/node_modules/@nodejsscope/pkg/index.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/lone_file.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/some_module/folder%25with percentage#/index.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)