Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
epidemian
GitHub Repository: epidemian/eslint-plugin-import
Path: blob/main/src/ExportMap.js
828 views
1
import fs from 'fs';
2
import { dirname } from 'path';
3
4
import doctrine from 'doctrine';
5
6
import debug from 'debug';
7
8
import { SourceCode } from 'eslint';
9
10
import parse from 'eslint-module-utils/parse';
11
import visit from 'eslint-module-utils/visit';
12
import resolve from 'eslint-module-utils/resolve';
13
import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore';
14
15
import { hashObject } from 'eslint-module-utils/hash';
16
import * as unambiguous from 'eslint-module-utils/unambiguous';
17
18
import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader';
19
20
import includes from 'array-includes';
21
22
let ts;
23
24
const log = debug('eslint-plugin-import:ExportMap');
25
26
const exportCache = new Map();
27
const tsConfigCache = new Map();
28
29
export default class ExportMap {
30
constructor(path) {
31
this.path = path;
32
this.namespace = new Map();
33
// todo: restructure to key on path, value is resolver + map of names
34
this.reexports = new Map();
35
/**
36
* star-exports
37
* @type {Set} of () => ExportMap
38
*/
39
this.dependencies = new Set();
40
/**
41
* dependencies of this module that are not explicitly re-exported
42
* @type {Map} from path = () => ExportMap
43
*/
44
this.imports = new Map();
45
this.errors = [];
46
}
47
48
get hasDefault() { return this.get('default') != null; } // stronger than this.has
49
50
get size() {
51
let size = this.namespace.size + this.reexports.size;
52
this.dependencies.forEach(dep => {
53
const d = dep();
54
// CJS / ignored dependencies won't exist (#717)
55
if (d == null) return;
56
size += d.size;
57
});
58
return size;
59
}
60
61
/**
62
* Note that this does not check explicitly re-exported names for existence
63
* in the base namespace, but it will expand all `export * from '...'` exports
64
* if not found in the explicit namespace.
65
* @param {string} name
66
* @return {Boolean} true if `name` is exported by this module.
67
*/
68
has(name) {
69
if (this.namespace.has(name)) return true;
70
if (this.reexports.has(name)) return true;
71
72
// default exports must be explicitly re-exported (#328)
73
if (name !== 'default') {
74
for (const dep of this.dependencies) {
75
const innerMap = dep();
76
77
// todo: report as unresolved?
78
if (!innerMap) continue;
79
80
if (innerMap.has(name)) return true;
81
}
82
}
83
84
return false;
85
}
86
87
/**
88
* ensure that imported name fully resolves.
89
* @param {string} name
90
* @return {{ found: boolean, path: ExportMap[] }}
91
*/
92
hasDeep(name) {
93
if (this.namespace.has(name)) return { found: true, path: [this] };
94
95
if (this.reexports.has(name)) {
96
const reexports = this.reexports.get(name);
97
const imported = reexports.getImport();
98
99
// if import is ignored, return explicit 'null'
100
if (imported == null) return { found: true, path: [this] };
101
102
// safeguard against cycles, only if name matches
103
if (imported.path === this.path && reexports.local === name) {
104
return { found: false, path: [this] };
105
}
106
107
const deep = imported.hasDeep(reexports.local);
108
deep.path.unshift(this);
109
110
return deep;
111
}
112
113
114
// default exports must be explicitly re-exported (#328)
115
if (name !== 'default') {
116
for (const dep of this.dependencies) {
117
const innerMap = dep();
118
if (innerMap == null) return { found: true, path: [this] };
119
// todo: report as unresolved?
120
if (!innerMap) continue;
121
122
// safeguard against cycles
123
if (innerMap.path === this.path) continue;
124
125
const innerValue = innerMap.hasDeep(name);
126
if (innerValue.found) {
127
innerValue.path.unshift(this);
128
return innerValue;
129
}
130
}
131
}
132
133
return { found: false, path: [this] };
134
}
135
136
get(name) {
137
if (this.namespace.has(name)) return this.namespace.get(name);
138
139
if (this.reexports.has(name)) {
140
const reexports = this.reexports.get(name);
141
const imported = reexports.getImport();
142
143
// if import is ignored, return explicit 'null'
144
if (imported == null) return null;
145
146
// safeguard against cycles, only if name matches
147
if (imported.path === this.path && reexports.local === name) return undefined;
148
149
return imported.get(reexports.local);
150
}
151
152
// default exports must be explicitly re-exported (#328)
153
if (name !== 'default') {
154
for (const dep of this.dependencies) {
155
const innerMap = dep();
156
// todo: report as unresolved?
157
if (!innerMap) continue;
158
159
// safeguard against cycles
160
if (innerMap.path === this.path) continue;
161
162
const innerValue = innerMap.get(name);
163
if (innerValue !== undefined) return innerValue;
164
}
165
}
166
167
return undefined;
168
}
169
170
forEach(callback, thisArg) {
171
this.namespace.forEach((v, n) =>
172
callback.call(thisArg, v, n, this));
173
174
this.reexports.forEach((reexports, name) => {
175
const reexported = reexports.getImport();
176
// can't look up meta for ignored re-exports (#348)
177
callback.call(thisArg, reexported && reexported.get(reexports.local), name, this);
178
});
179
180
this.dependencies.forEach(dep => {
181
const d = dep();
182
// CJS / ignored dependencies won't exist (#717)
183
if (d == null) return;
184
185
d.forEach((v, n) =>
186
n !== 'default' && callback.call(thisArg, v, n, this));
187
});
188
}
189
190
// todo: keys, values, entries?
191
192
reportErrors(context, declaration) {
193
context.report({
194
node: declaration.source,
195
message: `Parse errors in imported module '${declaration.source.value}': ` +
196
`${this.errors
197
.map(e => `${e.message} (${e.lineNumber}:${e.column})`)
198
.join(', ')}`,
199
});
200
}
201
}
202
203
/**
204
* parse docs from the first node that has leading comments
205
*/
206
function captureDoc(source, docStyleParsers, ...nodes) {
207
const metadata = {};
208
209
// 'some' short-circuits on first 'true'
210
nodes.some(n => {
211
try {
212
213
let leadingComments;
214
215
// n.leadingComments is legacy `attachComments` behavior
216
if ('leadingComments' in n) {
217
leadingComments = n.leadingComments;
218
} else if (n.range) {
219
leadingComments = source.getCommentsBefore(n);
220
}
221
222
if (!leadingComments || leadingComments.length === 0) return false;
223
224
for (const name in docStyleParsers) {
225
const doc = docStyleParsers[name](leadingComments);
226
if (doc) {
227
metadata.doc = doc;
228
}
229
}
230
231
return true;
232
} catch (err) {
233
return false;
234
}
235
});
236
237
return metadata;
238
}
239
240
const availableDocStyleParsers = {
241
jsdoc: captureJsDoc,
242
tomdoc: captureTomDoc,
243
};
244
245
/**
246
* parse JSDoc from leading comments
247
* @param {object[]} comments
248
* @return {{ doc: object }}
249
*/
250
function captureJsDoc(comments) {
251
let doc;
252
253
// capture XSDoc
254
comments.forEach(comment => {
255
// skip non-block comments
256
if (comment.type !== 'Block') return;
257
try {
258
doc = doctrine.parse(comment.value, { unwrap: true });
259
} catch (err) {
260
/* don't care, for now? maybe add to `errors?` */
261
}
262
});
263
264
return doc;
265
}
266
267
/**
268
* parse TomDoc section from comments
269
*/
270
function captureTomDoc(comments) {
271
// collect lines up to first paragraph break
272
const lines = [];
273
for (let i = 0; i < comments.length; i++) {
274
const comment = comments[i];
275
if (comment.value.match(/^\s*$/)) break;
276
lines.push(comment.value.trim());
277
}
278
279
// return doctrine-like object
280
const statusMatch = lines.join(' ').match(/^(Public|Internal|Deprecated):\s*(.+)/);
281
if (statusMatch) {
282
return {
283
description: statusMatch[2],
284
tags: [{
285
title: statusMatch[1].toLowerCase(),
286
description: statusMatch[2],
287
}],
288
};
289
}
290
}
291
292
const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']);
293
294
ExportMap.get = function (source, context) {
295
const path = resolve(source, context);
296
if (path == null) return null;
297
298
return ExportMap.for(childContext(path, context));
299
};
300
301
ExportMap.for = function (context) {
302
const { path } = context;
303
304
const cacheKey = hashObject(context).digest('hex');
305
let exportMap = exportCache.get(cacheKey);
306
307
// return cached ignore
308
if (exportMap === null) return null;
309
310
const stats = fs.statSync(path);
311
if (exportMap != null) {
312
// date equality check
313
if (exportMap.mtime - stats.mtime === 0) {
314
return exportMap;
315
}
316
// future: check content equality?
317
}
318
319
// check valid extensions first
320
if (!hasValidExtension(path, context)) {
321
exportCache.set(cacheKey, null);
322
return null;
323
}
324
325
// check for and cache ignore
326
if (isIgnored(path, context)) {
327
log('ignored path due to ignore settings:', path);
328
exportCache.set(cacheKey, null);
329
return null;
330
}
331
332
const content = fs.readFileSync(path, { encoding: 'utf8' });
333
334
// check for and cache unambiguous modules
335
if (!unambiguous.test(content)) {
336
log('ignored path due to unambiguous regex:', path);
337
exportCache.set(cacheKey, null);
338
return null;
339
}
340
341
log('cache miss', cacheKey, 'for path', path);
342
exportMap = ExportMap.parse(path, content, context);
343
344
// ambiguous modules return null
345
if (exportMap == null) return null;
346
347
exportMap.mtime = stats.mtime;
348
349
exportCache.set(cacheKey, exportMap);
350
return exportMap;
351
};
352
353
354
ExportMap.parse = function (path, content, context) {
355
const m = new ExportMap(path);
356
const isEsModuleInteropTrue = isEsModuleInterop();
357
358
let ast;
359
let visitorKeys;
360
try {
361
const result = parse(path, content, context);
362
ast = result.ast;
363
visitorKeys = result.visitorKeys;
364
} catch (err) {
365
m.errors.push(err);
366
return m; // can't continue
367
}
368
369
m.visitorKeys = visitorKeys;
370
371
let hasDynamicImports = false;
372
373
function processDynamicImport(source) {
374
hasDynamicImports = true;
375
if (source.type !== 'Literal') {
376
return null;
377
}
378
const p = remotePath(source.value);
379
if (p == null) {
380
return null;
381
}
382
const importedSpecifiers = new Set();
383
importedSpecifiers.add('ImportNamespaceSpecifier');
384
const getter = thunkFor(p, context);
385
m.imports.set(p, {
386
getter,
387
declarations: new Set([{
388
source: {
389
// capturing actual node reference holds full AST in memory!
390
value: source.value,
391
loc: source.loc,
392
},
393
importedSpecifiers,
394
}]),
395
});
396
}
397
398
visit(ast, visitorKeys, {
399
ImportExpression(node) {
400
processDynamicImport(node.source);
401
},
402
CallExpression(node) {
403
if (node.callee.type === 'Import') {
404
processDynamicImport(node.arguments[0]);
405
}
406
},
407
});
408
409
if (!unambiguous.isModule(ast) && !hasDynamicImports) return null;
410
411
const docstyle = (context.settings && context.settings['import/docstyle']) || ['jsdoc'];
412
const docStyleParsers = {};
413
docstyle.forEach(style => {
414
docStyleParsers[style] = availableDocStyleParsers[style];
415
});
416
417
// attempt to collect module doc
418
if (ast.comments) {
419
ast.comments.some(c => {
420
if (c.type !== 'Block') return false;
421
try {
422
const doc = doctrine.parse(c.value, { unwrap: true });
423
if (doc.tags.some(t => t.title === 'module')) {
424
m.doc = doc;
425
return true;
426
}
427
} catch (err) { /* ignore */ }
428
return false;
429
});
430
}
431
432
const namespaces = new Map();
433
434
function remotePath(value) {
435
return resolve.relative(value, path, context.settings);
436
}
437
438
function resolveImport(value) {
439
const rp = remotePath(value);
440
if (rp == null) return null;
441
return ExportMap.for(childContext(rp, context));
442
}
443
444
function getNamespace(identifier) {
445
if (!namespaces.has(identifier.name)) return;
446
447
return function () {
448
return resolveImport(namespaces.get(identifier.name));
449
};
450
}
451
452
function addNamespace(object, identifier) {
453
const nsfn = getNamespace(identifier);
454
if (nsfn) {
455
Object.defineProperty(object, 'namespace', { get: nsfn });
456
}
457
458
return object;
459
}
460
461
function processSpecifier(s, n, m) {
462
const nsource = n.source && n.source.value;
463
const exportMeta = {};
464
let local;
465
466
switch (s.type) {
467
case 'ExportDefaultSpecifier':
468
if (!nsource) return;
469
local = 'default';
470
break;
471
case 'ExportNamespaceSpecifier':
472
m.namespace.set(s.exported.name, Object.defineProperty(exportMeta, 'namespace', {
473
get() { return resolveImport(nsource); },
474
}));
475
return;
476
case 'ExportAllDeclaration':
477
m.namespace.set(s.exported.name, addNamespace(exportMeta, s.source.value));
478
return;
479
case 'ExportSpecifier':
480
if (!n.source) {
481
m.namespace.set(s.exported.name, addNamespace(exportMeta, s.local));
482
return;
483
}
484
// else falls through
485
default:
486
local = s.local.name;
487
break;
488
}
489
490
// todo: JSDoc
491
m.reexports.set(s.exported.name, { local, getImport: () => resolveImport(nsource) });
492
}
493
494
function captureDependency({ source }, isOnlyImportingTypes, importedSpecifiers = new Set()) {
495
if (source == null) return null;
496
497
const p = remotePath(source.value);
498
if (p == null) return null;
499
500
const declarationMetadata = {
501
// capturing actual node reference holds full AST in memory!
502
source: { value: source.value, loc: source.loc },
503
isOnlyImportingTypes,
504
importedSpecifiers,
505
};
506
507
const existing = m.imports.get(p);
508
if (existing != null) {
509
existing.declarations.add(declarationMetadata);
510
return existing.getter;
511
}
512
513
const getter = thunkFor(p, context);
514
m.imports.set(p, { getter, declarations: new Set([declarationMetadata]) });
515
return getter;
516
}
517
518
const source = makeSourceCode(content, ast);
519
520
function readTsConfig() {
521
const tsConfigInfo = tsConfigLoader({
522
cwd:
523
(context.parserOptions && context.parserOptions.tsconfigRootDir) ||
524
process.cwd(),
525
getEnv: (key) => process.env[key],
526
});
527
try {
528
if (tsConfigInfo.tsConfigPath !== undefined) {
529
// Projects not using TypeScript won't have `typescript` installed.
530
if (!ts) { ts = require('typescript'); }
531
532
const configFile = ts.readConfigFile(tsConfigInfo.tsConfigPath, ts.sys.readFile);
533
return ts.parseJsonConfigFileContent(
534
configFile.config,
535
ts.sys,
536
dirname(tsConfigInfo.tsConfigPath),
537
);
538
}
539
} catch (e) {
540
// Catch any errors
541
}
542
543
return null;
544
}
545
546
function isEsModuleInterop() {
547
const cacheKey = hashObject({
548
tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir,
549
}).digest('hex');
550
let tsConfig = tsConfigCache.get(cacheKey);
551
if (typeof tsConfig === 'undefined') {
552
tsConfig = readTsConfig(context);
553
tsConfigCache.set(cacheKey, tsConfig);
554
}
555
556
return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false;
557
}
558
559
ast.body.forEach(function (n) {
560
if (n.type === 'ExportDefaultDeclaration') {
561
const exportMeta = captureDoc(source, docStyleParsers, n);
562
if (n.declaration.type === 'Identifier') {
563
addNamespace(exportMeta, n.declaration);
564
}
565
m.namespace.set('default', exportMeta);
566
return;
567
}
568
569
if (n.type === 'ExportAllDeclaration') {
570
const getter = captureDependency(n, n.exportKind === 'type');
571
if (getter) m.dependencies.add(getter);
572
if (n.exported) {
573
processSpecifier(n, n.exported, m);
574
}
575
return;
576
}
577
578
// capture namespaces in case of later export
579
if (n.type === 'ImportDeclaration') {
580
// import type { Foo } (TS and Flow)
581
const declarationIsType = n.importKind === 'type';
582
// import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and
583
// shouldn't be considered to be just importing types
584
let specifiersOnlyImportingTypes = n.specifiers.length;
585
const importedSpecifiers = new Set();
586
n.specifiers.forEach(specifier => {
587
if (supportedImportTypes.has(specifier.type)) {
588
importedSpecifiers.add(specifier.type);
589
}
590
if (specifier.type === 'ImportSpecifier') {
591
importedSpecifiers.add(specifier.imported.name);
592
}
593
594
// import { type Foo } (Flow)
595
specifiersOnlyImportingTypes =
596
specifiersOnlyImportingTypes && specifier.importKind === 'type';
597
});
598
captureDependency(n, declarationIsType || specifiersOnlyImportingTypes, importedSpecifiers);
599
600
const ns = n.specifiers.find(s => s.type === 'ImportNamespaceSpecifier');
601
if (ns) {
602
namespaces.set(ns.local.name, n.source.value);
603
}
604
return;
605
}
606
607
if (n.type === 'ExportNamedDeclaration') {
608
// capture declaration
609
if (n.declaration != null) {
610
switch (n.declaration.type) {
611
case 'FunctionDeclaration':
612
case 'ClassDeclaration':
613
case 'TypeAlias': // flowtype with babel-eslint parser
614
case 'InterfaceDeclaration':
615
case 'DeclareFunction':
616
case 'TSDeclareFunction':
617
case 'TSEnumDeclaration':
618
case 'TSTypeAliasDeclaration':
619
case 'TSInterfaceDeclaration':
620
case 'TSAbstractClassDeclaration':
621
case 'TSModuleDeclaration':
622
m.namespace.set(n.declaration.id.name, captureDoc(source, docStyleParsers, n));
623
break;
624
case 'VariableDeclaration':
625
n.declaration.declarations.forEach((d) =>
626
recursivePatternCapture(d.id,
627
id => m.namespace.set(id.name, captureDoc(source, docStyleParsers, d, n))));
628
break;
629
}
630
}
631
632
n.specifiers.forEach((s) => processSpecifier(s, n, m));
633
}
634
635
const exports = ['TSExportAssignment'];
636
if (isEsModuleInteropTrue) {
637
exports.push('TSNamespaceExportDeclaration');
638
}
639
640
// This doesn't declare anything, but changes what's being exported.
641
if (includes(exports, n.type)) {
642
const exportedName = n.type === 'TSNamespaceExportDeclaration'
643
? n.id.name
644
: (n.expression && n.expression.name || (n.expression.id && n.expression.id.name) || null);
645
const declTypes = [
646
'VariableDeclaration',
647
'ClassDeclaration',
648
'TSDeclareFunction',
649
'TSEnumDeclaration',
650
'TSTypeAliasDeclaration',
651
'TSInterfaceDeclaration',
652
'TSAbstractClassDeclaration',
653
'TSModuleDeclaration',
654
];
655
const exportedDecls = ast.body.filter(({ type, id, declarations }) => includes(declTypes, type) && (
656
(id && id.name === exportedName) || (declarations && declarations.find((d) => d.id.name === exportedName))
657
));
658
if (exportedDecls.length === 0) {
659
// Export is not referencing any local declaration, must be re-exporting
660
m.namespace.set('default', captureDoc(source, docStyleParsers, n));
661
return;
662
}
663
if (
664
isEsModuleInteropTrue // esModuleInterop is on in tsconfig
665
&& !m.namespace.has('default') // and default isn't added already
666
) {
667
m.namespace.set('default', {}); // add default export
668
}
669
exportedDecls.forEach((decl) => {
670
if (decl.type === 'TSModuleDeclaration') {
671
if (decl.body && decl.body.type === 'TSModuleDeclaration') {
672
m.namespace.set(decl.body.id.name, captureDoc(source, docStyleParsers, decl.body));
673
} else if (decl.body && decl.body.body) {
674
decl.body.body.forEach((moduleBlockNode) => {
675
// Export-assignment exports all members in the namespace,
676
// explicitly exported or not.
677
const namespaceDecl = moduleBlockNode.type === 'ExportNamedDeclaration' ?
678
moduleBlockNode.declaration :
679
moduleBlockNode;
680
681
if (!namespaceDecl) {
682
// TypeScript can check this for us; we needn't
683
} else if (namespaceDecl.type === 'VariableDeclaration') {
684
namespaceDecl.declarations.forEach((d) =>
685
recursivePatternCapture(d.id, (id) => m.namespace.set(
686
id.name,
687
captureDoc(source, docStyleParsers, decl, namespaceDecl, moduleBlockNode),
688
)),
689
);
690
} else {
691
m.namespace.set(
692
namespaceDecl.id.name,
693
captureDoc(source, docStyleParsers, moduleBlockNode));
694
}
695
});
696
}
697
} else {
698
// Export as default
699
m.namespace.set('default', captureDoc(source, docStyleParsers, decl));
700
}
701
});
702
}
703
});
704
705
if (
706
isEsModuleInteropTrue // esModuleInterop is on in tsconfig
707
&& m.namespace.size > 0 // anything is exported
708
&& !m.namespace.has('default') // and default isn't added already
709
) {
710
m.namespace.set('default', {}); // add default export
711
}
712
713
return m;
714
};
715
716
/**
717
* The creation of this closure is isolated from other scopes
718
* to avoid over-retention of unrelated variables, which has
719
* caused memory leaks. See #1266.
720
*/
721
function thunkFor(p, context) {
722
return () => ExportMap.for(childContext(p, context));
723
}
724
725
726
/**
727
* Traverse a pattern/identifier node, calling 'callback'
728
* for each leaf identifier.
729
* @param {node} pattern
730
* @param {Function} callback
731
* @return {void}
732
*/
733
export function recursivePatternCapture(pattern, callback) {
734
switch (pattern.type) {
735
case 'Identifier': // base case
736
callback(pattern);
737
break;
738
739
case 'ObjectPattern':
740
pattern.properties.forEach(p => {
741
if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') {
742
callback(p.argument);
743
return;
744
}
745
recursivePatternCapture(p.value, callback);
746
});
747
break;
748
749
case 'ArrayPattern':
750
pattern.elements.forEach((element) => {
751
if (element == null) return;
752
if (element.type === 'ExperimentalRestProperty' || element.type === 'RestElement') {
753
callback(element.argument);
754
return;
755
}
756
recursivePatternCapture(element, callback);
757
});
758
break;
759
760
case 'AssignmentPattern':
761
callback(pattern.left);
762
break;
763
}
764
}
765
766
/**
767
* don't hold full context object in memory, just grab what we need.
768
*/
769
function childContext(path, context) {
770
const { settings, parserOptions, parserPath } = context;
771
return {
772
settings,
773
parserOptions,
774
parserPath,
775
path,
776
};
777
}
778
779
780
/**
781
* sometimes legacy support isn't _that_ hard... right?
782
*/
783
function makeSourceCode(text, ast) {
784
if (SourceCode.length > 1) {
785
// ESLint 3
786
return new SourceCode(text, ast);
787
} else {
788
// ESLint 4, 5
789
return new SourceCode({ text, ast });
790
}
791
}
792
793