Skip to content

Commit 3352205

Browse files
committed
module: improve named cjs import errors
When trying to import named exports from a CommonJS module an error is thrown. Unfortunately the V8 error only contains the single line that causes the error, it is therefore impossible to construct an equivalent code consisting of default import + object descructuring assignment. This was the reason why the example code was removed for multi line import statements in nodejs#35275 To generate a helpful error messages for any case we can parse the file where the error happens using acorn and construct a valid example code from the parsed ImportDeclaration. This will work for _any_ valid import statement. Since this code is only executed shortly before the node process crashes anyways performance should not be a concern here. Fixes: nodejs#35259 Refs: nodejs#35275
1 parent 6f34498 commit 3352205

3 files changed

Lines changed: 85 additions & 17 deletions

File tree

lib/internal/modules/esm/module_job.js

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,82 @@
22

33
const {
44
ArrayPrototypeJoin,
5+
Number,
56
ObjectSetPrototypeOf,
67
PromiseAll,
78
SafeSet,
89
SafePromise,
910
StringPrototypeIncludes,
1011
StringPrototypeMatch,
11-
StringPrototypeReplace,
1212
StringPrototypeSplit,
1313
} = primordials;
1414

1515
const { ModuleWrap } = internalBinding('module_wrap');
1616

1717
const { decorateErrorStack } = require('internal/util');
18+
const { fileURLToPath } = require('url');
1819
const assert = require('internal/assert');
1920
const resolvedPromise = SafePromise.resolve();
2021

2122
function noop() {}
2223

2324
let hasPausedEntry = false;
2425

26+
function extractExample(file, lineNumber) {
27+
const { readFileSync } = require('fs');
28+
const { parse } = require('internal/deps/acorn/acorn/dist/acorn');
29+
const { findNodeAt } = require('internal/deps/acorn/acorn-walk/dist/walk');
30+
31+
const code = readFileSync(file, { encoding: 'utf8' });
32+
const parsed = parse(code, {
33+
sourceType: 'module',
34+
locations: true,
35+
});
36+
let start = 0;
37+
let node;
38+
do {
39+
node = findNodeAt(parsed, start);
40+
start = node.node.end + 1;
41+
42+
if (node.node.loc.end.line < lineNumber) {
43+
continue;
44+
}
45+
46+
if (node.node.type !== 'ImportDeclaration') {
47+
continue;
48+
}
49+
50+
const defaultSpecifier = node.node.specifiers.find(
51+
(specifier) => specifier.type === 'ImportDefaultSpecifier'
52+
);
53+
const defaultImport = defaultSpecifier ?
54+
defaultSpecifier.local.name :
55+
'pkg';
56+
57+
const joinString = ', ';
58+
let totalLength = 0;
59+
const imports = node.node.specifiers
60+
.filter((specifier) => specifier.type === 'ImportSpecifier')
61+
.map((specifier) => {
62+
const statement =
63+
specifier.local.name === specifier.imported.name ?
64+
`${specifier.imported.name}` :
65+
`${specifier.imported.name}: ${specifier.local.name}`;
66+
totalLength += statement.length + joinString.length;
67+
return statement;
68+
});
69+
70+
const boilerplate = `const { } = ${defaultImport};`;
71+
const destructuringAssignment = totalLength > 80 - boilerplate.length ?
72+
`\n${imports.map((i) => ` ${i}`).join(',\n')}\n` :
73+
` ${imports.join(joinString)} `;
74+
75+
return `\n\nimport ${defaultImport} from '${node.node.source.value}';\n` +
76+
`const {${destructuringAssignment}} = ${defaultImport};\n`;
77+
} while (node === undefined || node.node.loc.start.line <= lineNumber);
78+
return '';
79+
}
80+
2581
/* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of
2682
* its dependencies, over time. */
2783
class ModuleJob {
@@ -109,21 +165,17 @@ class ModuleJob {
109165
await this.loader.resolve(childSpecifier, parentFileUrl);
110166
const format = await this.loader.getFormat(childFileURL);
111167
if (format === 'commonjs') {
112-
const importStatement = splitStack[1];
113-
// TODO(@ctavan): The original error stack only provides the single
114-
// line which causes the error. For multi-line import statements we
115-
// cannot generate an equivalent object descructuring assignment by
116-
// just parsing the error stack.
117-
const oneLineNamedImports = StringPrototypeMatch(importStatement, /{.*}/);
118-
const destructuringAssignment = oneLineNamedImports &&
119-
StringPrototypeReplace(oneLineNamedImports, /\s+as\s+/g, ': ');
168+
const [, fileUrl, lineNumber] =
169+
StringPrototypeMatch(parentFileUrl, /^(.*):([0-9]+)$/);
170+
const example = extractExample(
171+
fileURLToPath(fileUrl),
172+
Number(lineNumber),
173+
);
120174
e.message = `Named export '${name}' not found. The requested module` +
121175
` '${childSpecifier}' is a CommonJS module, which may not support` +
122176
' all module.exports as named exports.\nCommonJS modules can ' +
123177
'always be imported via the default export, for example using:' +
124-
`\n\nimport pkg from '${childSpecifier}';\n${
125-
destructuringAssignment ?
126-
`const ${destructuringAssignment} = pkg;\n` : ''}`;
178+
example;
127179
const newStack = StringPrototypeSplit(e.stack, '\n');
128180
newStack[3] = `SyntaxError: ${e.message}`;
129181
e.stack = ArrayPrototypeJoin(newStack, '\n');

test/es-module/test-esm-cjs-named-error.mjs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,26 @@ import { rejects } from 'assert';
33

44
const fixtureBase = '../fixtures/es-modules/package-cjs-named-error';
55

6-
const errTemplate = (specifier, name, namedImports) =>
6+
const errTemplate = (specifier, name, namedImports, defaultName) =>
77
`Named export '${name}' not found. The requested module` +
88
` '${specifier}' is a CommonJS module, which may not support ` +
99
'all module.exports as named exports.\nCommonJS modules can ' +
1010
'always be imported via the default export, for example using:' +
11-
`\n\nimport pkg from '${specifier}';\n` + (namedImports ?
12-
`const ${namedImports} = pkg;\n` : '');
11+
`\n\nimport ${defaultName || 'pkg'} from '${specifier}';\n` + (namedImports ?
12+
`const ${namedImports} = ${defaultName || 'pkg'};\n` : '');
1313

14-
const expectedWithoutExample = errTemplate('./fail.cjs', 'comeOn');
14+
const expectedMultiLine = errTemplate('./fail.cjs', 'comeOn',
15+
'{ comeOn, everybody }');
1516

1617
const expectedRelative = errTemplate('./fail.cjs', 'comeOn', '{ comeOn }');
1718

1819
const expectedRenamed = errTemplate('./fail.cjs', 'comeOn',
1920
'{ comeOn: comeOnRenamed }');
2021

22+
const expectedDefaultRenamed =
23+
errTemplate('./fail.cjs', 'everybody', '{ comeOn: comeOnRenamed, everybody }',
24+
'abc');
25+
2126
const expectedPackageHack =
2227
errTemplate('./json-hack/fail.js', 'comeOn', '{ comeOn }');
2328

@@ -44,11 +49,18 @@ rejects(async () => {
4449
message: expectedRenamed
4550
}, 'should correctly format named imports with renames');
4651

52+
rejects(async () => {
53+
await import(`${fixtureBase}/default-and-renamed-import.mjs`);
54+
}, {
55+
name: 'SyntaxError',
56+
message: expectedDefaultRenamed
57+
}, 'should correctly format hybrid default and named imports with renames');
58+
4759
rejects(async () => {
4860
await import(`${fixtureBase}/multi-line.mjs`);
4961
}, {
5062
name: 'SyntaxError',
51-
message: expectedWithoutExample,
63+
message: expectedMultiLine,
5264
}, 'should correctly format named imports across multiple lines');
5365

5466
rejects(async () => {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import abc, {
2+
comeOn as comeOnRenamed,
3+
everybody,
4+
} from './fail.cjs';

0 commit comments

Comments
 (0)